diff --git a/CHANGELOG.md b/CHANGELOG.md index 616c9e64a5..a63de5a382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 4.7.1-beta.1 + +- Added custom features that were not part of the main branch + - viewport tile padding + - latitude bounds + - support for texture formats other than gl.RGBA used by raster tile sources + - exports for Event, Tile, SourceCache, and OverscaledTileID + +# === LeafletGL follows === + ## main ### ✨ Features and improvements @@ -50,6 +60,8 @@ using `transformCameraUpdate` caused the `maxBounds` to stop working just for ea ### ✨ Features and improvements +- Merge atmosphere an sky implementation ([#3888](https://github.com/maplibre/maplibre-gl-js/issues/3888)) +- Add option to display a realistic atmosphere when using a Globe projection ([#3888](https://github.com/maplibre/maplibre-gl-js/issues/3888)) - Emit events when the cooperative gestures option has prevented a gesture. ([#4470](https://github.com/maplibre/maplibre-gl-js/pull/4470)) - Enable anisotropic filtering only when the pitch is greater than 20 degrees to preserve image sharpness on flat or slightly tilted maps. diff --git a/build/generate-doc-images.ts b/build/generate-doc-images.ts index abf849f709..fdf99c7da6 100644 --- a/build/generate-doc-images.ts +++ b/build/generate-doc-images.ts @@ -31,7 +31,7 @@ async function createImage(exampleName) { try { await page.waitForFunction('map.loaded()'); // Wait for 5 seconds on 3d model examples, since this takes longer to load. - const waitTime = exampleName.includes('3d-model') ? 5000 : 1500; + const waitTime = (exampleName.includes('3d-model') || exampleName.includes('globe')) ? 5000 : 1500; console.log(`waiting for ${waitTime} ms`); await new Promise(resolve => setTimeout(resolve, waitTime)); } catch (err) { diff --git a/developer-guides/assets/floats.png b/developer-guides/assets/floats.png new file mode 100644 index 0000000000..f8286070ee Binary files /dev/null and b/developer-guides/assets/floats.png differ diff --git a/developer-guides/assets/no_subdivision.png b/developer-guides/assets/no_subdivision.png new file mode 100644 index 0000000000..4cff13141e Binary files /dev/null and b/developer-guides/assets/no_subdivision.png differ diff --git a/developer-guides/assets/wireframe.png b/developer-guides/assets/wireframe.png new file mode 100644 index 0000000000..5f824f9c24 Binary files /dev/null and b/developer-guides/assets/wireframe.png differ diff --git a/developer-guides/globe.md b/developer-guides/globe.md new file mode 100644 index 0000000000..f69e9ae391 --- /dev/null +++ b/developer-guides/globe.md @@ -0,0 +1,195 @@ +# Globe projection + +This guide describes the inner workings of globe projection. +Globe draws the same vector polygons and lines as mercator projection, +ensuring a clear, unstretched image at all view angles and support for dynamic layers and geometry. + +The actual projection is done in three steps: + +- compute angular spherical coordinates from source web mercator tile data +- convert spherical coordinates to a 3D vector - a point on the surface of a unit sphere +- project the 3D vector using a common perspective projection matrix + +So the globe is a unit sphere from the point of view of projection. +This also simplifies a lot of math, and is used extensively in the globe transform class. + +Geometry is projected to the sphere in the vertex shader. + +## Zoom behavior + +To stay consistent with web mercator maps, globe is automatically enlarged when map center is nearing the poles. +This keeps the map center visually similar to a mercator map with the same x,y and zoom. +However, when panning the globe or performing camera animations, +we do not want the planet to get larger or smaller when changing latitudes. +Map movement thus compensates for the planet size change by also +changing zoom level along with latitude changes. + +This behavior is completely automatic and transparent to the user. +The only case when the user needs to be aware of this is when +programmatically triggering animations such as `flyTo` and `easeTo` +and using them to both change the map center's latitude and *at the same time* +changing the map's zoom to an amount based on the map's starting zoom. +The example [globe-zoom-planet-size-function](https://maplibre.org/maplibre-gl-js/docs/examples/globe-zoom-planet-size-function/) demonstrates how to +compensate for planet size changes in this case. +All other camera animations (that either specify target zoom +that is not based on current zoom or do not specify zoom at all) will work as expected. + +## Shaders + +Most vertex shaders use the `projectTile` function, which +accepts a 2D vector of coordinates inside the currently drawn tile, +in range 0..EXTENT (8192), and returns its final projection that can +be directly passed to `gl_Position`. +When drawing a tile, proper uniforms must be set to convert from +these tile-local coordinates to web mercator. + +The implementation of `projectTile` is automatically injected into the shader source code. +Different implementations can be injected, depending on the currently active projection. +Thanks to this many shaders use the exact same code for both mercator and globe, +although there are shaders that use `#ifdef GLOBE` for globe-specific code. + +## Subdivision + +If we were to draw mercator tiles with globe shaders directly, we would end up with a deformed sphere. +This is due to how polygons and lines are triangulated in MapLibre - the earcut algorithm +creates as few triangles as possible, which can sometimes result in huge triangles, for example in the oceans. +This behavior is desirable in mercator maps, but if we were to project the vertices of such large triangles to globe directly, +we would not get curved horizons, lines, etc. +For this reason, before a tile is finished loading, its geometry (both polygons and lines) is further subdivided. + +The figure below demonstrates how globe would look without subdivision. +Note the deformed oceans, and the USA-Canada border that is not properly curved. + +![](assets/no_subdivision.png) + +It is critical that subdivision is as fast as possible, otherwise it would significantly slow down tile loading. +Currently the fastest approach seems to be taking the output geometry from `earcut` and subdividing that further. + +When modifying subdivision, beware that it is very prone to subtle errors, resulting in single-pixel seams. +Subdivision should also split the geometry in consistent places, +so that polygons and lines match up correctly when projected. + +We use subdivision that results in a square grid, visible in the figure below. + +![](assets/wireframe.png) + +Subdivision is configured in the Projection object. +Subdivision granularity is defined by the base tile granularity and minimal allowed granularity. +The tile for zoom level 0 will have base granularity, tile for zoom 1 will have half that, etc., +but never less than minimal granularity. + +The maximal subdivision granularity of 128 for fill layers is enough to get nicely curved horizons, +while also not generating too much new geometry and not overflowing the 16 bit vertex indices used throughout MapLibre. + +Raster tiles in particular need a relative high base granularity, as otherwise they would exhibit +visible warping and deformations when changing zoom levels. + +## Floating point precision & transitioning to mercator + +Shaders work with 32 bit floating point numbers (64 bit are possible on some platforms, but very slow). +The 23 bits of mantissa and 1 sign bit can represent at most around 16 million values, +but the circumference of the earth is roughly 40 000 km, which works out to +about one float32 value per 2.5 meters, which is insufficient for a map. +Thus if we were to use globe projection at all zoom levels, we would unsurprisingly encounter precision issues. + +![](assets/floats.png) + +To combat this, globe projection automatically switches to mercator projection around zoom level 12. +This transition is smooth, animated and can only be noticed if you look very closely, +because globe and mercator projections converge at high zoom levels, and around level 12 +they are already very close. + +The transition animation is implemented in the shader's projection function, +and is controlled by a "globeness" parameter passed from the transform. + +## GPU "atan" error correction + +When implementing globe, we noticed that globe projection did not match mercator projection +after the automatic transition described in previous section. +This mismatch was very visible at certain latitudes, the globe map was shifted north/south by hundreds of meters, +but at other latitudes the shift was much smaller. This behavior was also inconsistent - one would +expect the shift to gradually increase or decrease with distance from equator, but that was not the case. + +Eventually, we tracked this down to an issue in the projection shader, specifically the `atan` function. +On some GPU vendors, the function is inaccurate in a way that matches the observed projection shifts. + +To combat this, every second we draw a 1x1 pixel framebuffer and store the `atan` value +for the current latitude, asynchronously download the pixel's value, compare it with `Math.atan` +reference, and shift the globe projection matrix to compensate. +This approach works, because the error is continuous and doesn't change too quickly with latitude. + +This approach also has the advantage that it works regardless of the actual error of the `atan`, +so MapLibre should work fine even if it runs on some new GPU in the future with different +`atan` inaccuracies. + +## Clipping + +When drawing a planet, we need to somehow clip the geometry that is on its backfacing side. +Since MapLibre uses the Z-buffer for optimizing transparency drawing, filling it with custom +values, we cannot use it for this purpose. + +Instead, we compute a plane that intersects the horizons, and for each vertex +we compute the distance from this plane and store it in `gl_Position.z`. +This forces the GPU's clipping hardware to clip geometry beyond the planet's horizon. +This does not affect MapLibre's custom Z values, since they are set later using +`glDepthRange`. + +However this approach does not work on some phones due to what is likely a driver bug, +which applies `glDepthRange` and clipping in the wrong order. +So additionally, face culling is used for fill and raster layers +(earcut does not result in consistent winding order, this is ensured during subdivision) +and line layers (which have inconsistent winding order) discard beyond-horizon +pixels in the fragment shader. + +## Raster tiles + +Drawing raster tiles under globe is somewhat more complex than under mercator, +since under globe they are much more prone to having slight seams between tiles. +Tile are drawn as subdivided meshes instead of simple quads, and the curvature +near the edges can cause seams, especially in cases when two tiles of different +zoom levels are next to each other. + +To make sure that there are both no seams and that every pixel is covered by +valid tile texture (as opposed to a stretched border of a neighboring tile), +we first draw all tiles *without* border, marking all drawn pixels in stencil. +Then, we draw all tiles *with* borders, but set stencil to discard all pixels +that were drawn in the first pass. + +This ensures that no pixel is drawn twice, and that the stretched borders +are only drawn in regions between tiles. + +## Symbols + +Symbol rendering also had to be adapted for globe, as well as collision detection and placement. +MapLibre computed well-fitting bounding boxes even for curved symbols under globe projection +by computing the AABB from a projection of the symbol's box' corners and box edge midpoints. +This is an approximation, but works well in practice. + +## Transformations and unproject + +Most projection and unproject functions from the transform interface are adapted for globe, +with some caveats. +The `setLocationAtPoint`function may sometimes not find a valid solution +for the given parameters. +Globe transform currently does not support constraining the map's center. + +## Controls + +Globe uses slightly different controls than mercator map. +Panning, zooming, etc. is aware of the sphere and should work intuitively, +as well as camera animations such as `flyTo` and `easeTo`. + +Specifically, when zooming, the location under the cursor stays under the cursor, +just like it does on a mercator map. +However this behavior has some limitations on the globe. +In some scenarios, such as zooming to the edge of the planet, +this way of zooming would result in rapid and unpleasant map panning. +Thus this behavior is slowly faded out at low zooms and replaced with an approximation. + +There are also other edge cases, such as when looking at the planet's poles +and trying to zoom in to a location that is on the other hemisphere ("behind the pole"). +MapLibre does not support moving the camera across poles, so instead we need to rotate around. +In this case, an approximation instead of exact zooming is used as well. + +Globe controls also use panning inertia, just like mercator. +Special care was taken to keep the movement speed of inertia consistent. diff --git a/docs/assets/examples/globe-3d-model.png b/docs/assets/examples/globe-3d-model.png new file mode 100644 index 0000000000..e7128b407f Binary files /dev/null and b/docs/assets/examples/globe-3d-model.png differ diff --git a/docs/assets/examples/globe-atmosphere.png b/docs/assets/examples/globe-atmosphere.png new file mode 100644 index 0000000000..def621e949 Binary files /dev/null and b/docs/assets/examples/globe-atmosphere.png differ diff --git a/docs/assets/examples/globe-custom-simple.png b/docs/assets/examples/globe-custom-simple.png new file mode 100644 index 0000000000..3eecc1e6d7 Binary files /dev/null and b/docs/assets/examples/globe-custom-simple.png differ diff --git a/docs/assets/examples/globe-custom-tiles.png b/docs/assets/examples/globe-custom-tiles.png new file mode 100644 index 0000000000..3bd8d5ace7 Binary files /dev/null and b/docs/assets/examples/globe-custom-tiles.png differ diff --git a/docs/assets/examples/globe-fill-extrusion.png b/docs/assets/examples/globe-fill-extrusion.png new file mode 100644 index 0000000000..6382eb4487 Binary files /dev/null and b/docs/assets/examples/globe-fill-extrusion.png differ diff --git a/docs/assets/examples/globe-vector-tiles.png b/docs/assets/examples/globe-vector-tiles.png new file mode 100644 index 0000000000..dd14ff1e89 Binary files /dev/null and b/docs/assets/examples/globe-vector-tiles.png differ diff --git a/docs/assets/examples/globe-zoom-planet-size-function.png b/docs/assets/examples/globe-zoom-planet-size-function.png new file mode 100644 index 0000000000..cd723d2c84 Binary files /dev/null and b/docs/assets/examples/globe-zoom-planet-size-function.png differ diff --git a/package.json b/package.json index 791079138b..2d679363d0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "maplibre-gl", + "name": "@windycom/maplibre-gl", "description": "BSD licensed community fork of mapbox-gl, a WebGL interactive maps library", "version": "4.7.1", "main": "dist/maplibre-gl.js", @@ -189,4 +189,4 @@ "npm": ">=8.1.0", "node": ">=16.14.0" } -} +} \ No newline at end of file diff --git a/src/data/bucket.ts b/src/data/bucket.ts index 0435acc53f..e124771efe 100644 --- a/src/data/bucket.ts +++ b/src/data/bucket.ts @@ -8,6 +8,7 @@ import type {ImagePosition} from '../render/image_atlas'; import type {CanonicalTileID} from '../source/tile_id'; import type {VectorTileFeature, VectorTileLayer} from '@mapbox/vector-tile'; import Point from '@mapbox/point-geometry'; +import type {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; export type BucketParameters = { index: number; @@ -26,6 +27,7 @@ export type PopulateParameters = { patternDependencies: {}; glyphDependencies: {}; availableImages: Array; + subdivisionGranularity: SubdivisionGranularitySetting; }; export type IndexedFeature = { diff --git a/src/data/bucket/circle_bucket.ts b/src/data/bucket/circle_bucket.ts index 0fcbda0a1d..b0273de993 100644 --- a/src/data/bucket/circle_bucket.ts +++ b/src/data/bucket/circle_bucket.ts @@ -27,11 +27,16 @@ import type Point from '@mapbox/point-geometry'; import type {FeatureStates} from '../../source/source_state'; import type {ImagePosition} from '../../render/image_atlas'; import type {VectorTileLayer} from '@mapbox/vector-tile'; +import {CircleGranularity} from '../../render/subdivision_granularity_settings'; +const VERTEX_MIN_VALUE = -32768; // -(2^15) + +// Extrude is in range 0..7, which will be mapped to -1..1 in the shader. function addCircleVertex(layoutVertexArray, x, y, extrudeX, extrudeY) { + // We pack circle position and extrude into range 0..65535, but vertices are stored as *signed* 16-bit integers, so we need to offset the number by 2^15. layoutVertexArray.emplaceBack( - (x * 2) + ((extrudeX + 1) / 2), - (y * 2) + ((extrudeY + 1) / 2)); + VERTEX_MIN_VALUE + (x * 8) + extrudeX, + VERTEX_MIN_VALUE + (y * 8) + extrudeY); } /** @@ -82,12 +87,21 @@ export class CircleBucket im let circleSortKey = null; let sortFeaturesByKey = false; + // Heatmap circles are usually large (and map-pitch-aligned), tessellate them to allow curvature along the globe. + let subdivide = styleLayer.type === 'heatmap'; + // Heatmap layers are handled in this bucket and have no evaluated properties, so we check our access if (styleLayer.type === 'circle') { - circleSortKey = (styleLayer as CircleStyleLayer).layout.get('circle-sort-key'); + const circleStyle = (styleLayer as CircleStyleLayer); + circleSortKey = circleStyle.layout.get('circle-sort-key'); sortFeaturesByKey = !circleSortKey.isConstant(); + + // Circles that are "printed" onto the map surface should be tessellated to follow the globe's curvature. + subdivide = subdivide || circleStyle.paint.get('circle-pitch-alignment') === 'map'; } + const granularity = subdivide ? options.subdivisionGranularity.circle : 1; + for (const {feature, id, index, sourceLayerIndex} of features) { const needGeometry = this.layers[0]._featureFilter.needGeometry; const evaluationFeature = toEvaluationFeature(feature, needGeometry); @@ -121,7 +135,7 @@ export class CircleBucket im const {geometry, index, sourceLayerIndex} = bucketFeature; const feature = features[index].feature; - this.addFeature(bucketFeature, geometry, index, canonical); + this.addFeature(bucketFeature, geometry, index, canonical, granularity); options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index); } } @@ -156,37 +170,63 @@ export class CircleBucket im this.segments.destroy(); } - addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID) { + addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, granularity: CircleGranularity = 1) { + // Since we store the circle's center in each vertex, we only have 3 bits for actual vertex position in each axis. + // Thus the valid range of positions is 0..7. + // This gives us 4 possible granularity settings that are symmetrical. + + // This array stores vertex positions that should by used by the tessellated quad. + let extrudes: Array; + + switch (granularity) { + case 1: + extrudes = [0, 7]; + break; + case 3: + extrudes = [0, 2, 5, 7]; + break; + case 5: + extrudes = [0, 1, 3, 4, 6, 7]; + break; + case 7: + extrudes = [0, 1, 2, 3, 4, 5, 6, 7]; + break; + default: + throw new Error(`Invalid circle bucket granularity: ${granularity}; valid values are 1, 3, 5, 7.`); + } + + const verticesPerAxis = extrudes.length; + for (const ring of geometry) { for (const point of ring) { - const x = point.x; - const y = point.y; + const vx = point.x; + const vy = point.y; // Do not include points that are outside the tile boundaries. - if (x < 0 || x >= EXTENT || y < 0 || y >= EXTENT) continue; - - // this geometry will be of the Point type, and we'll derive - // two triangles from it. - // - // ┌─────────┐ - // │ 3 2 │ - // │ │ - // │ 0 1 │ - // └─────────┘ - - const segment = this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray, feature.sortKey); - const index = segment.vertexLength; + if (vx < 0 || vx >= EXTENT || vy < 0 || vy >= EXTENT) { + continue; + } - addCircleVertex(this.layoutVertexArray, x, y, -1, -1); - addCircleVertex(this.layoutVertexArray, x, y, 1, -1); - addCircleVertex(this.layoutVertexArray, x, y, 1, 1); - addCircleVertex(this.layoutVertexArray, x, y, -1, 1); - - this.indexArray.emplaceBack(index, index + 1, index + 2); - this.indexArray.emplaceBack(index, index + 3, index + 2); + const segment = this.segments.prepareSegment(verticesPerAxis * verticesPerAxis, this.layoutVertexArray, this.indexArray, feature.sortKey); + const index = segment.vertexLength; - segment.vertexLength += 4; - segment.primitiveLength += 2; + for (let y = 0; y < verticesPerAxis; y++) { + for (let x = 0; x < verticesPerAxis; x++) { + addCircleVertex(this.layoutVertexArray, vx, vy, extrudes[x], extrudes[y]); + } + } + + for (let y = 0; y < verticesPerAxis - 1; y++) { + for (let x = 0; x < verticesPerAxis - 1; x++) { + const lowerIndex = index + y * verticesPerAxis + x; + const upperIndex = index + (y + 1) * verticesPerAxis + x; + this.indexArray.emplaceBack(lowerIndex, upperIndex + 1, lowerIndex + 1); + this.indexArray.emplaceBack(lowerIndex, upperIndex, upperIndex + 1); + } + } + + segment.vertexLength += verticesPerAxis * verticesPerAxis; + segment.primitiveLength += (verticesPerAxis - 1) * (verticesPerAxis - 1) * 2; } } diff --git a/src/data/bucket/fill_bucket.test.ts b/src/data/bucket/fill_bucket.test.ts index 06bd605652..6480a46caf 100644 --- a/src/data/bucket/fill_bucket.test.ts +++ b/src/data/bucket/fill_bucket.test.ts @@ -11,11 +11,15 @@ import {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import {EvaluationParameters} from '../../style/evaluation_parameters'; import {ZoomHistory} from '../../style/zoom_history'; import {BucketFeature, BucketParameters} from '../bucket'; +import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; +import {CanonicalTileID} from '../../source/tile_id'; // Load a fill feature from fixture tile. const vt = new VectorTile(new Protobuf(fs.readFileSync(path.resolve(__dirname, '../../../test/unit/assets/mbsv5-6-18-23.vector.pbf')))); const feature = vt.layers.water.feature(0); +const canonicalTileID = new CanonicalTileID(20, 1, 1); + function createPolygon(numPoints) { const points = []; for (let i = 0; i < numPoints; i++) { @@ -34,15 +38,15 @@ test('FillBucket', () => { bucket.addFeature({} as BucketFeature, [[ new Point(0, 0), new Point(10, 10) - ]], undefined, undefined, undefined); + ]], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision); bucket.addFeature({} as BucketFeature, [[ new Point(0, 0), new Point(10, 10), new Point(10, 20) - ]], undefined, undefined, undefined); + ]], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision); - bucket.addFeature(feature as any, feature.loadGeometry(), undefined, undefined, undefined); + bucket.addFeature(feature as any, feature.loadGeometry(), undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision); }).not.toThrow(); }); @@ -66,13 +70,13 @@ test('FillBucket segmentation', () => { // first add an initial, small feature to make sure the next one starts at // a non-zero offset - bucket.addFeature({} as BucketFeature, [createPolygon(10)], undefined, undefined, undefined); + bucket.addFeature({} as BucketFeature, [createPolygon(10)], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision); // add a feature that will break across the group boundary bucket.addFeature({} as BucketFeature, [ createPolygon(128), createPolygon(128) - ], undefined, undefined, undefined); + ], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision); // Each polygon must fit entirely within a segment, so we expect the // first segment to include the first feature and the first polygon @@ -82,12 +86,14 @@ test('FillBucket segmentation', () => { expect(bucket.segments.get()[0]).toEqual({ vertexOffset: 0, vertexLength: 138, + vaos: {}, primitiveOffset: 0, primitiveLength: 134 }); expect(bucket.segments.get()[1]).toEqual({ vertexOffset: 138, vertexLength: 128, + vaos: {}, primitiveOffset: 134, primitiveLength: 126 }); diff --git a/src/data/bucket/fill_bucket.ts b/src/data/bucket/fill_bucket.ts index 7b5083edda..6071525570 100644 --- a/src/data/bucket/fill_bucket.ts +++ b/src/data/bucket/fill_bucket.ts @@ -4,7 +4,6 @@ import {members as layoutAttributes} from './fill_attributes'; import {SegmentVector} from '../segment'; import {ProgramConfigurationSet} from '../program_configuration'; import {LineIndexArray, TriangleIndexArray} from '../index_array_type'; -import earcut from 'earcut'; import {classifyRings} from '@maplibre/maplibre-gl-style-spec'; const EARCUT_MAX_RINGS = 500; import {register} from '../../util/web_worker_transfer'; @@ -29,6 +28,9 @@ import type Point from '@mapbox/point-geometry'; import type {FeatureStates} from '../../source/source_state'; import type {ImagePosition} from '../../render/image_atlas'; import type {VectorTileLayer} from '@mapbox/vector-tile'; +import {subdividePolygon} from '../../render/subdivision'; +import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; +import {fillLargeMeshArrays} from '../../render/fill_large_mesh_arrays'; export class FillBucket implements Bucket { index: number; @@ -116,7 +118,7 @@ export class FillBucket implements Bucket { // so are stored during populate until later updated with positions by tile worker in addFeatures this.patternFeatures.push(patternFeature); } else { - this.addFeature(bucketFeature, geometry, index, canonical, {}); + this.addFeature(bucketFeature, geometry, index, canonical, {}, options.subdivisionGranularity); } const feature = features[index].feature; @@ -135,7 +137,7 @@ export class FillBucket implements Bucket { [_: string]: ImagePosition; }) { for (const feature of this.patternFeatures) { - this.addFeature(feature, feature.geometry, feature.index, canonical, imagePositions); + this.addFeature(feature, feature.geometry, feature.index, canonical, imagePositions, options.subdivisionGranularity); } } @@ -168,58 +170,25 @@ export class FillBucket implements Bucket { addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, imagePositions: { [_: string]: ImagePosition; - }) { + }, subdivisionGranularity: SubdivisionGranularitySetting) { for (const polygon of classifyRings(geometry, EARCUT_MAX_RINGS)) { - let numVertices = 0; - for (const ring of polygon) { - numVertices += ring.length; - } - - const triangleSegment = this.segments.prepareSegment(numVertices, this.layoutVertexArray, this.indexArray); - const triangleIndex = triangleSegment.vertexLength; - - const flattened = []; - const holeIndices = []; - - for (const ring of polygon) { - if (ring.length === 0) { - continue; - } - - if (ring !== polygon[0]) { - holeIndices.push(flattened.length / 2); - } - - const lineSegment = this.segments2.prepareSegment(ring.length, this.layoutVertexArray, this.indexArray2); - const lineIndex = lineSegment.vertexLength; - - this.layoutVertexArray.emplaceBack(ring[0].x, ring[0].y); - this.indexArray2.emplaceBack(lineIndex + ring.length - 1, lineIndex); - flattened.push(ring[0].x); - flattened.push(ring[0].y); - - for (let i = 1; i < ring.length; i++) { - this.layoutVertexArray.emplaceBack(ring[i].x, ring[i].y); - this.indexArray2.emplaceBack(lineIndex + i - 1, lineIndex + i); - flattened.push(ring[i].x); - flattened.push(ring[i].y); - } - - lineSegment.vertexLength += ring.length; - lineSegment.primitiveLength += ring.length; - } - - const indices = earcut(flattened, holeIndices); - - for (let i = 0; i < indices.length; i += 3) { - this.indexArray.emplaceBack( - triangleIndex + indices[i], - triangleIndex + indices[i + 1], - triangleIndex + indices[i + 2]); - } - - triangleSegment.vertexLength += numVertices; - triangleSegment.primitiveLength += indices.length / 3; + const subdivided = subdividePolygon(polygon, canonical, subdivisionGranularity.fill.getGranularityForZoomLevel(canonical.z)); + + const vertexArray = this.layoutVertexArray; + + fillLargeMeshArrays( + (x, y) => { + vertexArray.emplaceBack(x, y); + }, + this.segments, + this.layoutVertexArray, + this.indexArray, + subdivided.verticesFlattened, + subdivided.indicesTriangles, + this.segments2, + this.indexArray2, + subdivided.indicesLineList, + ); } this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, imagePositions, canonical); } diff --git a/src/data/bucket/fill_extrusion_bucket.ts b/src/data/bucket/fill_extrusion_bucket.ts index 6de1d4b18f..eb5eb48db3 100644 --- a/src/data/bucket/fill_extrusion_bucket.ts +++ b/src/data/bucket/fill_extrusion_bucket.ts @@ -1,11 +1,10 @@ import {FillExtrusionLayoutArray, PosArray} from '../array_types.g'; import {members as layoutAttributes, centroidAttributes} from './fill_extrusion_attributes'; -import {SegmentVector} from '../segment'; +import {Segment, SegmentVector} from '../segment'; import {ProgramConfigurationSet} from '../program_configuration'; import {TriangleIndexArray} from '../index_array_type'; import {EXTENT} from '../extent'; -import earcut from 'earcut'; import mvt from '@mapbox/vector-tile'; const vectorTileFeatureTypes = mvt.VectorTileFeature.types; import {classifyRings} from '@maplibre/maplibre-gl-style-spec'; @@ -33,6 +32,9 @@ import type Point from '@mapbox/point-geometry'; import type {FeatureStates} from '../../source/source_state'; import type {ImagePosition} from '../../render/image_atlas'; import type {VectorTileLayer} from '@mapbox/vector-tile'; +import {subdividePolygon, subdivideVertexLine} from '../../render/subdivision'; +import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; +import {fillLargeMeshArrays} from '../../render/fill_large_mesh_arrays'; const FACTOR = Math.pow(2, 13); @@ -50,6 +52,12 @@ function addVertex(vertexArray, x, y, nx, ny, nz, t, e) { ); } +type CentroidAccumulator = { + x: number; + y: number; + sampleCount: number; +} + export class FillExtrusionBucket implements Bucket { index: number; zoom: number; @@ -113,7 +121,7 @@ export class FillExtrusionBucket implements Bucket { if (this.hasPattern) { this.features.push(addPatternDependencies('fill-extrusion', this.layers, bucketFeature, this.zoom, options)); } else { - this.addFeature(bucketFeature, bucketFeature.geometry, index, canonical, {}); + this.addFeature(bucketFeature, bucketFeature.geometry, index, canonical, {}, options.subdivisionGranularity); } options.featureIndex.insert(feature, bucketFeature.geometry, index, sourceLayerIndex, this.index, true); @@ -123,7 +131,7 @@ export class FillExtrusionBucket implements Bucket { addFeatures(options: PopulateParameters, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) { for (const feature of this.features) { const {geometry} = feature; - this.addFeature(feature, geometry, feature.index, canonical, imagePositions); + this.addFeature(feature, geometry, feature.index, canonical, imagePositions, options.subdivisionGranularity); } } @@ -159,134 +167,156 @@ export class FillExtrusionBucket implements Bucket { this.centroidVertexBuffer.destroy(); } - addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) { + addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}, subdivisionGranularity: SubdivisionGranularitySetting) { for (const polygon of classifyRings(geometry, EARCUT_MAX_RINGS)) { + // Compute polygon centroid to calculate elevation in GPU + const centroid: CentroidAccumulator = {x: 0, y: 0, sampleCount: 0}; + const oldVertexCount = this.layoutVertexArray.length; + this.processPolygon(centroid, canonical, feature, polygon, subdivisionGranularity); - const centroid = {x: 0, y: 0, vertexCount: 0}; + const addedVertices = this.layoutVertexArray.length - oldVertexCount; - let numVertices = 0; - for (const ring of polygon) { - numVertices += ring.length; - } - let segment = this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray); + const centroidX = Math.floor(centroid.x / centroid.sampleCount); + const centroidY = Math.floor(centroid.y / centroid.sampleCount); - for (const ring of polygon) { - if (ring.length === 0) { - continue; - } + for (let i = 0; i < addedVertices; i++) { + this.centroidVertexArray.emplaceBack( + centroidX, + centroidY + ); + } + } - if (isEntirelyOutside(ring)) { - continue; - } + this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, imagePositions, canonical); + } - let edgeDistance = 0; + private processPolygon( + centroid: CentroidAccumulator, + canonical: CanonicalTileID, + feature: BucketFeature, + polygon: Array>, + subdivisionGranularity: SubdivisionGranularitySetting + ): void { + if (polygon.length < 1) { + return; + } - for (let p = 0; p < ring.length; p++) { - const p1 = ring[p]; + if (isEntirelyOutside(polygon[0])) { + return; + } - if (p >= 1) { - const p2 = ring[p - 1]; + // Only consider the un-subdivided polygon outer ring for centroid calculation + for (const ring of polygon) { + if (ring.length === 0) { + continue; + } - if (!isBoundaryEdge(p1, p2)) { - if (segment.vertexLength + 4 > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { - segment = this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray); - } + // Here we don't mind if a hole ring is entirely outside, unlike when generating geometry later. + accumulatePointsToCentroid(centroid, ring); + } - const perp = p1.sub(p2)._perp()._unit(); - const dist = p2.dist(p1); - if (edgeDistance + dist > 32768) edgeDistance = 0; + const segmentReference = { + segment: this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray) + }; + const granularity = subdivisionGranularity.fill.getGranularityForZoomLevel(canonical.z); + const isPolygon = vectorTileFeatureTypes[feature.type] === 'Polygon'; - addVertex(this.layoutVertexArray, p1.x, p1.y, perp.x, perp.y, 0, 0, edgeDistance); - addVertex(this.layoutVertexArray, p1.x, p1.y, perp.x, perp.y, 0, 1, edgeDistance); - centroid.x += 2 * p1.x; - centroid.y += 2 * p1.y; - centroid.vertexCount += 2; + for (const ring of polygon) { + if (ring.length === 0) { + continue; + } - edgeDistance += dist; + if (isEntirelyOutside(ring)) { + continue; + } - addVertex(this.layoutVertexArray, p2.x, p2.y, perp.x, perp.y, 0, 0, edgeDistance); - addVertex(this.layoutVertexArray, p2.x, p2.y, perp.x, perp.y, 0, 1, edgeDistance); - centroid.x += 2 * p2.x; - centroid.y += 2 * p2.y; - centroid.vertexCount += 2; + const subdividedRing = subdivideVertexLine(ring, granularity, isPolygon); + this._generateSideFaces(subdividedRing, segmentReference); + } - const bottomRight = segment.vertexLength; + // Only triangulate and draw the area of the feature if it is a polygon + // Other feature types (e.g. LineString) do not have area, so triangulation is pointless / undefined + if (!isPolygon) + return; + + // Do not generate outlines, since outlines already got subdivided earlier. + const subdividedPolygon = subdividePolygon(polygon, canonical, granularity, false); + const vertexArray = this.layoutVertexArray; + + fillLargeMeshArrays( + (x, y) => { + addVertex(vertexArray, x, y, 0, 0, 1, 1, 0); + }, + this.segments, + this.layoutVertexArray, + this.indexArray, + subdividedPolygon.verticesFlattened, + subdividedPolygon.indicesTriangles + ); + } - // ┌──────┐ - // │ 0 1 │ Counter-clockwise winding order. - // │ │ Triangle 1: 0 => 2 => 1 - // │ 2 3 │ Triangle 2: 1 => 2 => 3 - // └──────┘ - this.indexArray.emplaceBack(bottomRight, bottomRight + 2, bottomRight + 1); - this.indexArray.emplaceBack(bottomRight + 1, bottomRight + 2, bottomRight + 3); + /** + * Generates side faces for the supplied geometry. Assumes `geometry` to be a line string, like the output of {@link subdivideVertexLine}. + * For rings, it is assumed that the first and last vertex of `geometry` are equal. + */ + private _generateSideFaces(geometry: Array, segmentReference: {segment: Segment}) { + let edgeDistance = 0; - segment.vertexLength += 4; - segment.primitiveLength += 2; - } - } - } + for (let p = 1; p < geometry.length; p++) { + const p1 = geometry[p]; + const p2 = geometry[p - 1]; + if (isBoundaryEdge(p1, p2)) { + continue; } - if (segment.vertexLength + numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { - segment = this.segments.prepareSegment(numVertices, this.layoutVertexArray, this.indexArray); + if (segmentReference.segment.vertexLength + 4 > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { + segmentReference.segment = this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray); } - //Only triangulate and draw the area of the feature if it is a polygon - //Other feature types (e.g. LineString) do not have area, so triangulation is pointless / undefined - if (vectorTileFeatureTypes[feature.type] !== 'Polygon') - continue; - - const flattened = []; - const holeIndices = []; - const triangleIndex = segment.vertexLength; - - for (const ring of polygon) { - if (ring.length === 0) { - continue; - } + const perp = p1.sub(p2)._perp()._unit(); + const dist = p2.dist(p1); + if (edgeDistance + dist > 32768) edgeDistance = 0; - if (ring !== polygon[0]) { - holeIndices.push(flattened.length / 2); - } + addVertex(this.layoutVertexArray, p1.x, p1.y, perp.x, perp.y, 0, 0, edgeDistance); + addVertex(this.layoutVertexArray, p1.x, p1.y, perp.x, perp.y, 0, 1, edgeDistance); - for (let i = 0; i < ring.length; i++) { - const p = ring[i]; + edgeDistance += dist; - addVertex(this.layoutVertexArray, p.x, p.y, 0, 0, 1, 1, 0); - centroid.x += p.x; - centroid.y += p.y; - centroid.vertexCount += 1; + addVertex(this.layoutVertexArray, p2.x, p2.y, perp.x, perp.y, 0, 0, edgeDistance); + addVertex(this.layoutVertexArray, p2.x, p2.y, perp.x, perp.y, 0, 1, edgeDistance); - flattened.push(p.x); - flattened.push(p.y); - } - - } + const bottomRight = segmentReference.segment.vertexLength; - const indices = earcut(flattened, holeIndices); + // ┌──────┐ + // │ 0 1 │ Counter-clockwise winding order. + // │ │ Triangle 1: 0 => 2 => 1 + // │ 2 3 │ Triangle 2: 1 => 2 => 3 + // └──────┘ + this.indexArray.emplaceBack(bottomRight, bottomRight + 2, bottomRight + 1); + this.indexArray.emplaceBack(bottomRight + 1, bottomRight + 2, bottomRight + 3); - for (let j = 0; j < indices.length; j += 3) { - // Counter-clockwise winding order. - this.indexArray.emplaceBack( - triangleIndex + indices[j], - triangleIndex + indices[j + 2], - triangleIndex + indices[j + 1]); - } - - segment.primitiveLength += indices.length / 3; - segment.vertexLength += numVertices; + segmentReference.segment.vertexLength += 4; + segmentReference.segment.primitiveLength += 2; + } + } +} - // remember polygon centroid to calculate elevation in GPU - for (let i = 0; i < centroid.vertexCount; i++) { - const averageX = Math.floor(centroid.x / centroid.vertexCount); - const averageY = Math.floor(centroid.y / centroid.vertexCount); - this.centroidVertexArray.emplaceBack(averageX, averageY); - } +/** + * Accumulates geometry to centroid. Geometry can be either a polygon ring, a line string or a closed line string. + * In case of a polygon ring or line ring, the last vertex is ignored if it is the same as the first vertex. + */ +function accumulatePointsToCentroid(centroid: CentroidAccumulator, geometry: Array): void { + for (let i = 0; i < geometry.length; i++) { + const p = geometry[i]; + if (i === geometry.length - 1 && geometry[0].x === p.x && geometry[0].y === p.y) { + continue; } - this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, imagePositions, canonical); + centroid.x += p.x; + centroid.y += p.y; + centroid.sampleCount++; } } diff --git a/src/data/bucket/line_bucket.test.ts b/src/data/bucket/line_bucket.test.ts index 30fa871395..18d0a4620e 100644 --- a/src/data/bucket/line_bucket.test.ts +++ b/src/data/bucket/line_bucket.test.ts @@ -9,6 +9,9 @@ import {LineStyleLayer} from '../../style/style_layer/line_style_layer'; import {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import {EvaluationParameters} from '../../style/evaluation_parameters'; import {BucketFeature, BucketParameters} from '../bucket'; +import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; + +const noSubdivision = SubdivisionGranularitySetting.noSubdivision; // Load a line feature from fixture tile. const vt = new VectorTile(new Protobuf(fs.readFileSync(path.resolve(__dirname, '../../../test/unit/assets/mbsv5-6-18-23.vector.pbf')))); @@ -42,61 +45,61 @@ describe('LineBucket', () => { bucket.addLine([ new Point(0, 0) - ], line, undefined, undefined, undefined, undefined); + ], line, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0) - ], polygon, undefined, undefined, undefined, undefined); + ], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(0, 0) - ], line, undefined, undefined, undefined, undefined); + ], line, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(0, 0) - ], polygon, undefined, undefined, undefined, undefined); + ], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(10, 10), new Point(0, 0) - ], line, undefined, undefined, undefined, undefined); + ], line, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(10, 10), new Point(0, 0) - ], polygon, undefined, undefined, undefined, undefined); + ], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(10, 10), new Point(10, 20) - ], line, undefined, undefined, undefined, undefined); + ], line, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(10, 10), new Point(10, 20) - ], polygon, undefined, undefined, undefined, undefined); + ], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(10, 10), new Point(10, 20), new Point(0, 0) - ], line, undefined, undefined, undefined, undefined); + ], line, undefined, undefined, undefined, undefined, undefined, noSubdivision); bucket.addLine([ new Point(0, 0), new Point(10, 10), new Point(10, 20), new Point(0, 0) - ], polygon, undefined, undefined, undefined, undefined); + ], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision); - bucket.addFeature(feature as any, feature.loadGeometry(), undefined, undefined, undefined); + bucket.addFeature(feature as any, feature.loadGeometry(), undefined, undefined, undefined, noSubdivision); }).not.toThrow(); }); @@ -114,10 +117,10 @@ describe('LineBucket', () => { // first add an initial, small feature to make sure the next one starts at // a non-zero offset - bucket.addFeature({} as BucketFeature, [createLine(10)], undefined, undefined, undefined); + bucket.addFeature({} as BucketFeature, [createLine(10)], undefined, undefined, undefined, noSubdivision); // add a feature that will break across the group boundary - bucket.addFeature({} as BucketFeature, [createLine(128)], undefined, undefined, undefined); + bucket.addFeature({} as BucketFeature, [createLine(128)], undefined, undefined, undefined, noSubdivision); // Each polygon must fit entirely within a segment, so we expect the // first segment to include the first feature and the first polygon @@ -127,11 +130,13 @@ describe('LineBucket', () => { expect(bucket.segments.get()).toEqual([{ vertexOffset: 0, vertexLength: 20, + vaos: {}, primitiveOffset: 0, primitiveLength: 18 }, { vertexOffset: 20, vertexLength: 256, + vaos: {}, primitiveOffset: 18, primitiveLength: 254 }]); diff --git a/src/data/bucket/line_bucket.ts b/src/data/bucket/line_bucket.ts index ea816f88af..d976757cd1 100644 --- a/src/data/bucket/line_bucket.ts +++ b/src/data/bucket/line_bucket.ts @@ -33,6 +33,8 @@ import type {VertexBuffer} from '../../gl/vertex_buffer'; import type {FeatureStates} from '../../source/source_state'; import type {ImagePosition} from '../../render/image_atlas'; import type {VectorTileLayer} from '@mapbox/vector-tile'; +import {subdivideVertexLine} from '../../render/subdivision'; +import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; // NOTE ON EXTRUDE SCALE: // scale the extrusion vector so that the normal length is this value. @@ -188,7 +190,7 @@ export class LineBucket implements Bucket { // so are stored during populate until later updated with positions by tile worker in addFeatures this.patternFeatures.push(patternBucketFeature); } else { - this.addFeature(bucketFeature, geometry, index, canonical, {}); + this.addFeature(bucketFeature, geometry, index, canonical, {}, options.subdivisionGranularity); } const feature = features[index].feature; @@ -203,7 +205,7 @@ export class LineBucket implements Bucket { addFeatures(options: PopulateParameters, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) { for (const feature of this.patternFeatures) { - this.addFeature(feature, feature.geometry, feature.index, canonical, imagePositions); + this.addFeature(feature, feature.geometry, feature.index, canonical, imagePositions, options.subdivisionGranularity); } } @@ -243,7 +245,7 @@ export class LineBucket implements Bucket { } } - addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) { + addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}, subdivisionGranularity: SubdivisionGranularitySetting) { const layout = this.layers[0].layout; const join = layout.get('line-join').evaluate(feature, {}); const cap = layout.get('line-cap'); @@ -252,17 +254,21 @@ export class LineBucket implements Bucket { this.lineClips = this.lineFeatureClips(feature); for (const line of geometry) { - this.addLine(line, feature, join, cap, miterLimit, roundLimit); + this.addLine(line, feature, join, cap, miterLimit, roundLimit, canonical, subdivisionGranularity); } this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, imagePositions, canonical); } - addLine(vertices: Array, feature: BucketFeature, join: string, cap: string, miterLimit: number, roundLimit: number) { + addLine(vertices: Array, feature: BucketFeature, join: string, cap: string, miterLimit: number, roundLimit: number, canonical: CanonicalTileID | undefined, subdivisionGranularity: SubdivisionGranularitySetting) { this.distance = 0; this.scaledDistance = 0; this.totalDistance = 0; + // First, subdivide the line if needed (mostly for globe rendering) + const granularity = canonical ? subdivisionGranularity.line.getGranularityForZoomLevel(canonical.z) : 1; + vertices = subdivideVertexLine(vertices, granularity); + if (this.lineClips) { this.lineClipsArray.push(this.lineClips); // Calculate the total distance, in tile units, of this tiled line feature @@ -563,7 +569,7 @@ export class LineBucket implements Bucket { const e = segment.vertexLength++; if (this.e1 >= 0 && this.e2 >= 0) { - this.indexArray.emplaceBack(this.e1, this.e2, e); + this.indexArray.emplaceBack(this.e1, e, this.e2); segment.primitiveLength++; } if (up) { diff --git a/src/data/bucket/symbol_bucket.test.ts b/src/data/bucket/symbol_bucket.test.ts index 7f83673a71..60d949ad55 100644 --- a/src/data/bucket/symbol_bucket.test.ts +++ b/src/data/bucket/symbol_bucket.test.ts @@ -6,7 +6,6 @@ import {SymbolBucket} from './symbol_bucket'; import {CollisionBoxArray} from '../../data/array_types.g'; import {performSymbolLayout} from '../../symbol/symbol_layout'; import {Placement} from '../../symbol/placement'; -import {Transform} from '../../geo/transform'; import {CanonicalTileID, OverscaledTileID} from '../../source/tile_id'; import {Tile} from '../../source/tile'; import {CrossTileSymbolIndex} from '../../symbol/cross_tile_symbol_index'; @@ -18,7 +17,8 @@ import {IndexedFeature, PopulateParameters} from '../bucket'; import {StyleImage} from '../../style/style_image'; import glyphs from '../../../test/unit/assets/fontstack-glyphs.json' with {type: 'json'}; import {StyleGlyph} from '../../style/style_glyph'; -import {createProjection} from '../../geo/projection/projection'; +import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; +import {MercatorTransform} from '../../geo/projection/mercator_transform'; // Load a point feature from fixture tile. const vt = new VectorTile(new Protobuf(fs.readFileSync(path.resolve(__dirname, '../../../test/unit/assets/mbsv5-6-18-23.vector.pbf')))); @@ -26,10 +26,8 @@ const feature = vt.layers.place_label.feature(10); /*eslint new-cap: 0*/ const collisionBoxArray = new CollisionBoxArray(); -const transform = new Transform(); -transform.width = 100; -transform.height = 100; -transform.cameraToCenterDistance = 100; +const transform = new MercatorTransform(); +transform.resize(100, 100); const stacks = {'Test': glyphs} as any as { [_: string]: { @@ -65,7 +63,7 @@ describe('SymbolBucket', () => { const bucketA = bucketSetup() as any as SymbolBucket; const bucketB = bucketSetup() as any as SymbolBucket; const options = {iconDependencies: {}, glyphDependencies: {}} as PopulateParameters; - const placement = new Placement(transform, createProjection(), undefined as any, 0, true); + const placement = new Placement(transform, undefined as any, 0, true); const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const crossTileSymbolIndex = new CrossTileSymbolIndex(); @@ -75,7 +73,8 @@ describe('SymbolBucket', () => { { bucket: bucketA, glyphMap: stacks, - glyphPositions: {} + glyphPositions: {}, + subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision } as any); const tileA = new Tile(tileID, 512); tileA.latestFeatureIndex = new FeatureIndex(tileID); @@ -85,7 +84,7 @@ describe('SymbolBucket', () => { // add same feature from bucket B bucketB.populate([{feature} as IndexedFeature], options, undefined as any); performSymbolLayout({ - bucket: bucketB, glyphMap: stacks, glyphPositions: {} + bucket: bucketB, glyphMap: stacks, glyphPositions: {}, subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision } as any); const tileB = new Tile(tileID, 512); tileB.buckets = {test: bucketB}; @@ -123,7 +122,8 @@ describe('SymbolBucket', () => { performSymbolLayout({ bucket, glyphMap: stacks, - glyphPositions: {'Test': {97: fakeGlyph, 98: fakeGlyph, 99: fakeGlyph, 100: fakeGlyph, 101: fakeGlyph, 102: fakeGlyph} as any} + glyphPositions: {'Test': {97: fakeGlyph, 98: fakeGlyph, 99: fakeGlyph, 100: fakeGlyph, 101: fakeGlyph, 102: fakeGlyph} as any}, + subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision } as any); expect(spy).toHaveBeenCalledTimes(1); @@ -164,7 +164,8 @@ describe('SymbolBucket', () => { expect(icons.b).toBe(true); performSymbolLayout({ - bucket, imageMap, imagePositions: imagePos + bucket, imageMap, imagePositions: imagePos, + subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision } as any); // undefined SDF should be treated the same as false SDF - no warning raised @@ -205,7 +206,7 @@ describe('SymbolBucket', () => { expect(icons.a).toBe(true); expect(icons.b).toBe(true); - performSymbolLayout({bucket, imageMap, imagePositions: imagePos} as any); + performSymbolLayout({bucket, imageMap, imagePositions: imagePos, subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision} as any); // true SDF and false SDF in same bucket should trigger warning expect(spy).toHaveBeenCalledTimes(1); diff --git a/src/data/bucket/symbol_bucket.ts b/src/data/bucket/symbol_bucket.ts index 7efb2ed5bb..47c11a3c2f 100644 --- a/src/data/bucket/symbol_bucket.ts +++ b/src/data/bucket/symbol_bucket.ts @@ -37,7 +37,6 @@ import {register} from '../../util/web_worker_transfer'; import {EvaluationParameters} from '../../style/evaluation_parameters'; import {Formatted, ResolvedImage} from '@maplibre/maplibre-gl-style-spec'; import {rtlWorkerPlugin} from '../../source/rtl_text_plugin_worker'; -import {mat4} from 'gl-matrix'; import {getOverlapMode} from '../../style/style_layer/overlap_mode'; import type {CanonicalTileID} from '../../source/tile_id'; import type { @@ -348,8 +347,6 @@ export class SymbolBucket implements Bucket { featureSortOrder: Array; collisionCircleArray: Array; - placementInvProjMatrix: mat4; - placementViewportMatrix: mat4; text: SymbolBuffers; icon: SymbolBuffers; @@ -377,8 +374,6 @@ export class SymbolBucket implements Bucket { this.sortKeyRanges = []; this.collisionCircleArray = []; - this.placementInvProjMatrix = mat4.identity([] as any); - this.placementViewportMatrix = mat4.identity([] as any); const layer = this.layers[0]; const unevaluatedLayoutValues = layer._unevaluatedLayout._values; @@ -603,7 +598,7 @@ export class SymbolBucket implements Bucket { } } - addToLineVertexArray(anchor: Anchor, line: any) { + addToLineVertexArray(anchor: Anchor, line: Array) { const lineStartIndex = this.lineVertexArray.length; if (anchor.segment !== undefined) { let sumForwardLength = anchor.dist(line[anchor.segment + 1]); @@ -667,7 +662,7 @@ export class SymbolBucket implements Bucket { addDynamicAttributes(arrays.dynamicLayoutVertexArray, labelAnchor, angle); - indexArray.emplaceBack(index, index + 1, index + 2); + indexArray.emplaceBack(index, index + 2, index + 1); indexArray.emplaceBack(index + 1, index + 2, index + 3); segment.vertexLength += 4; @@ -859,7 +854,7 @@ export class SymbolBucket implements Bucket { const endIndex = placedSymbol.vertexStartIndex + placedSymbol.numGlyphs * 4; for (let vertexIndex = placedSymbol.vertexStartIndex; vertexIndex < endIndex; vertexIndex += 4) { - iconOrText.indexArray.emplaceBack(vertexIndex, vertexIndex + 1, vertexIndex + 2); + iconOrText.indexArray.emplaceBack(vertexIndex, vertexIndex + 2, vertexIndex + 1); iconOrText.indexArray.emplaceBack(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3); } } diff --git a/src/data/feature_index.ts b/src/data/feature_index.ts index 4869eca60c..06f89bb113 100644 --- a/src/data/feature_index.ts +++ b/src/data/feature_index.ts @@ -21,13 +21,13 @@ import {mat4} from 'gl-matrix'; import type {StyleLayer} from '../style/style_layer'; import type {FeatureFilter, FeatureState, FilterSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {VectorTileFeature, VectorTileLayer} from '@mapbox/vector-tile'; type QueryParameters = { scale: number; pixelPosMatrix: mat4; - transform: Transform; + transform: IReadonlyTransform; tileSize: number; queryGeometry: Array; cameraQueryGeometry: Array; 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/data/segment.test.ts b/src/data/segment.test.ts new file mode 100644 index 0000000000..55fae180bb --- /dev/null +++ b/src/data/segment.test.ts @@ -0,0 +1,220 @@ +import {FillLayoutArray, TriangleIndexArray} from './array_types.g'; +import {SegmentVector} from './segment'; + +describe('SegmentVector', () => { + test('constructor', () => { + expect(new SegmentVector() instanceof SegmentVector).toBeTruthy(); + }); + + test('simpleSegment', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const segmentVector = SegmentVector.simpleSegment(0, 0, 10, 0); + expect(segmentVector instanceof SegmentVector).toBeTruthy(); + expect(segmentVector.segments).toHaveLength(1); + expect(segmentVector.segments[0].vertexLength).toBe(10); + }); + + test('prepareSegment returns a segment', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const result = segmentVector.prepareSegment(10, vertexBuffer, indexBuffer); + expect(result).toBeTruthy(); + expect(result.vertexLength).toBe(0); + }); + + test('prepareSegment handles vertex overflow', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 10); + const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 10); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(first === second).toBe(false); + expect(first.vertexLength).toBe(10); + expect(second.vertexLength).toBe(10); + expect(segmentVector.segments).toHaveLength(2); + }); + + test('prepareSegment reuses segments', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(first === second).toBe(true); + expect(first.vertexLength).toBe(10); + }); + + test('createNewSegment returns a new segment', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + const second = segmentVector.createNewSegment(vertexBuffer, indexBuffer); + second.vertexLength += 5; + addVertices(vertexBuffer, 5); + const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(third).toBeTruthy(); + expect(first === second).toBe(false); + expect(second === third).toBe(true); + expect(first.vertexLength).toBe(5); + expect(third.vertexLength).toBe(10); + }); + + test('createNewSegment returns a new segment and resets invalidateLast', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + segmentVector.forceNewSegmentOnNextPrepare(); + const second = segmentVector.createNewSegment(vertexBuffer, indexBuffer); + second.vertexLength += 5; + addVertices(vertexBuffer, 5); + const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(third).toBeTruthy(); + expect(first === second).toBe(false); + expect(second === third).toBe(true); + expect(first.vertexLength).toBe(5); + expect(third.vertexLength).toBe(10); + }); + + test('getOrCreateLatestSegment creates a new segment if SegmentVector was empty', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = segmentVector.getOrCreateLatestSegment(vertexBuffer, indexBuffer); + expect(first).toBeTruthy(); + expect(segmentVector.segments).toHaveLength(1); + }); + + test('getOrCreateLatestSegment returns the last segment if invalidateLast=false', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + const second = segmentVector.getOrCreateLatestSegment(vertexBuffer, indexBuffer); + second.vertexLength += 5; + addVertices(vertexBuffer, 5); + const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(third).toBeTruthy(); + expect(first === second).toBe(true); + expect(second === third).toBe(true); + expect(first.vertexLength).toBe(15); + }); + + test('getOrCreateLatestSegment respects invalidateLast and returns a new segment', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + segmentVector.forceNewSegmentOnNextPrepare(); + const second = segmentVector.getOrCreateLatestSegment(vertexBuffer, indexBuffer); + second.vertexLength += 5; + addVertices(vertexBuffer, 5); + const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(third).toBeTruthy(); + expect(first === second).toBe(false); + expect(second === third).toBe(true); + expect(first.vertexLength).toBe(5); + expect(third.vertexLength).toBe(10); + }); + + test('prepareSegment respects invalidateLast', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + segmentVector.forceNewSegmentOnNextPrepare(); + const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(third).toBeTruthy(); + expect(first === second).toBe(false); + expect(second === third).toBe(true); + expect(first.vertexLength).toBe(5); + expect(second.vertexLength).toBe(10); + expect(segmentVector.segments).toHaveLength(2); + }); + + test('invalidateLast called twice has no effect', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + segmentVector.forceNewSegmentOnNextPrepare(); + segmentVector.forceNewSegmentOnNextPrepare(); + const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(first === second).toBe(false); + expect(first.vertexLength).toBe(5); + expect(second.vertexLength).toBe(5); + expect(segmentVector.segments).toHaveLength(2); + }); + + test('invalidateLast called on an empty SegmentVector has no effect', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + segmentVector.forceNewSegmentOnNextPrepare(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5); + expect(first).toBeTruthy(); + expect(first.vertexLength).toBe(5); + expect(segmentVector.segments).toHaveLength(1); + }); + + test('prepareSegment respects different sortKey', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const vertexBuffer = new FillLayoutArray(); + const indexBuffer = new TriangleIndexArray(); + const segmentVector = new SegmentVector(); + const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5, 1); + const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5, 2); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(first === second).toBe(false); + expect(first.vertexLength).toBe(5); + expect(second.vertexLength).toBe(5); + expect(segmentVector.segments).toHaveLength(2); + }); +}); + +/** + * Mocks the usage of a segment from SegmentVector. Returns the used segment. + */ +function mockUseSegment(segmentVector: SegmentVector, vertexBuffer: FillLayoutArray, indexBuffer: TriangleIndexArray, numVertices: number, sortKey?: number) { + const seg = segmentVector.prepareSegment(numVertices, vertexBuffer, indexBuffer, sortKey); + seg.vertexLength += numVertices; + addVertices(vertexBuffer, numVertices); + return seg; +} + +function addVertices(array: FillLayoutArray, count: number) { + for (let i = 0; i < count; i++) { + array.emplaceBack(0, 0); + } +} diff --git a/src/data/segment.ts b/src/data/segment.ts index 07ab4345ac..da589db3a9 100644 --- a/src/data/segment.ts +++ b/src/data/segment.ts @@ -25,32 +25,81 @@ export type Segment = { export class SegmentVector { static MAX_VERTEX_ARRAY_LENGTH: number; segments: Array; + private _forceNewSegmentOnNextPrepare: boolean = false; constructor(segments: Array = []) { this.segments = segments; } + /** + * Returns the last segment if `numVertices` fits into it. + * If there are no segments yet or `numVertices` doesn't fit into the last one, creates a new empty segment and returns it. + */ prepareSegment( numVertices: number, layoutVertexArray: StructArray, indexArray: StructArray, sortKey?: number ): Segment { - let segment: Segment = this.segments[this.segments.length - 1]; - if (numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) warnOnce(`Max vertices per segment is ${SegmentVector.MAX_VERTEX_ARRAY_LENGTH}: bucket requested ${numVertices}`); - if (!segment || segment.vertexLength + numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH || segment.sortKey !== sortKey) { - segment = ({ - vertexOffset: layoutVertexArray.length, - primitiveOffset: indexArray.length, - vertexLength: 0, - primitiveLength: 0 - } as any); - if (sortKey !== undefined) segment.sortKey = sortKey; - this.segments.push(segment); + const lastSegment: Segment = this.segments[this.segments.length - 1]; + + if (numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { + warnOnce(`Max vertices per segment is ${SegmentVector.MAX_VERTEX_ARRAY_LENGTH}: bucket requested ${numVertices}. Consider using the \`fillLargeMeshArrays\` function if you require meshes with more than ${SegmentVector.MAX_VERTEX_ARRAY_LENGTH} vertices.`); + } + + if (this._forceNewSegmentOnNextPrepare || !lastSegment || lastSegment.vertexLength + numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH || lastSegment.sortKey !== sortKey) { + return this.createNewSegment(layoutVertexArray, indexArray, sortKey); + } else { + return lastSegment; + } + } + + /** + * Creates a new empty segment and returns it. + */ + createNewSegment( + layoutVertexArray: StructArray, + indexArray: StructArray, + sortKey?: number + ): Segment { + const segment: Segment = { + vertexOffset: layoutVertexArray.length, + primitiveOffset: indexArray.length, + vertexLength: 0, + primitiveLength: 0, + vaos: {} + }; + + if (sortKey !== undefined) { + segment.sortKey = sortKey; } + + // If this was set, we have no need to create a new segment on next prepareSegment call, + // since this function already created a new, empty segment. + this._forceNewSegmentOnNextPrepare = false; + this.segments.push(segment); return segment; } + /** + * Returns the last segment, or creates a new segments if there are no segments yet. + */ + getOrCreateLatestSegment( + layoutVertexArray: StructArray, + indexArray: StructArray, + sortKey?: number + ): Segment { + return this.prepareSegment(0, layoutVertexArray, indexArray, sortKey); + } + + /** + * Causes the next call to {@link prepareSegment} to always return a new segment, + * not reusing the current segment even if the new geometry would fit it. + */ + forceNewSegmentOnNextPrepare() { + this._forceNewSegmentOnNextPrepare = true; + } + get() { return this.segments; } diff --git a/src/geo/edge_insets.ts b/src/geo/edge_insets.ts index 82ce736bf7..6eb0e28c0b 100644 --- a/src/geo/edge_insets.ts +++ b/src/geo/edge_insets.ts @@ -4,7 +4,7 @@ import {clamp} from '../util/util'; /** * An `EdgeInset` object represents screen space padding applied to the edges of the viewport. - * This shifts the apprent center or the vanishing point of the map. This is useful for adding floating UI elements + * This shifts the apparent center or the vanishing point of the map. This is useful for adding floating UI elements * on top of the map and having the vanishing point shift as UI elements resize. * * @group Geography and Geometry 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/lng_lat_bounds.ts b/src/geo/lng_lat_bounds.ts index c3f1399384..0ecba74e55 100644 --- a/src/geo/lng_lat_bounds.ts +++ b/src/geo/lng_lat_bounds.ts @@ -23,6 +23,8 @@ export type LngLatBoundsLike = LngLatBounds | [LngLatLike, LngLatLike] | [number * A `LngLatBounds` object represents a geographical bounding box, * defined by its southwest and northeast points in longitude and latitude. * + * If the longitude bounds difference is over 360°, no longitude bound is applied. + * * If no arguments are provided to the constructor, a `null` bounding box is created. * * Note that any Mapbox GL method that accepts a `LngLatBounds` object as an argument or option diff --git a/src/geo/projection/camera_helper.ts b/src/geo/projection/camera_helper.ts new file mode 100644 index 0000000000..93937271fb --- /dev/null +++ b/src/geo/projection/camera_helper.ts @@ -0,0 +1,92 @@ +import Point from '@mapbox/point-geometry'; +import {IReadonlyTransform, ITransform} from '../transform_interface'; +import {LngLat, LngLatLike} from '../lng_lat'; +import {CameraForBoundsOptions, PointLike} from '../../ui/camera'; +import {PaddingOptions} from '../edge_insets'; +import {LngLatBounds} from '../lng_lat_bounds'; +import {warnOnce} from '../../util/util'; + +export type MapControlsDeltas = { + panDelta: Point; + zoomDelta: number; + bearingDelta: number; + pitchDelta: number; + around: Point; +} + +export type CameraForBoxAndBearingHandlerResult = { + center: LngLat; + zoom: number; + bearing: number; +}; + +export type EaseToHandlerOptions = { + bearing: number; + pitch: number; + padding: PaddingOptions; + offsetAsPoint: Point; + around?: LngLat; + aroundPoint?: Point; + center?: LngLatLike; + zoom?: number; + offset?: PointLike; +} + +export type EaseToHandlerResult = { + easeFunc: (k: number) => void; + elevationCenter: LngLat; + isZooming: boolean; +} + +export type FlyToHandlerOptions = { + bearing: number; + pitch: number; + padding: PaddingOptions; + offsetAsPoint: Point; + center?: LngLatLike; + locationAtOffset: LngLat; + zoom?: number; + minZoom?: number; +} + +export type FlyToHandlerResult = { + easeFunc: (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => void; + scaleOfZoom: number; + scaleOfMinZoom?: number; + targetCenter: LngLat; + pixelPathLength: number; +} + +/** + * @internal + */ +export function cameraBoundsWarning() { + warnOnce( + 'Map cannot fit within canvas with the given bounds, padding, and/or offset.' + ); +} + +/** + * @internal + * Contains projection-specific functions related to camera controls, easeTo, flyTo, inertia, etc. + */ +export interface ICameraHelper { + get useGlobeControls(): boolean; + + handlePanInertia(pan: Point, transform: IReadonlyTransform): { + easingCenter: LngLat; + easingOffset: Point; + }; + + handleMapControlsPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void; + + handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void; + + cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult; + + handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void; + + handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult; + + handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult; +} diff --git a/src/geo/projection/globe.ts b/src/geo/projection/globe.ts new file mode 100644 index 0000000000..942e61557f --- /dev/null +++ b/src/geo/projection/globe.ts @@ -0,0 +1,196 @@ +import type {Context} from '../../gl/context'; +import type {CanonicalTileID} from '../../source/tile_id'; +import {Mesh} from '../../render/mesh'; +import {browser} from '../../util/browser'; +import {easeCubicInOut, lerp} from '../../util/util'; +import {mercatorYfromLat} from '../mercator_coordinate'; +import {SubdivisionGranularityExpression, SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; +import type {Projection, ProjectionGPUContext, TileMeshUsage} from './projection'; +import {PreparedShader, shaders} from '../../shaders/shaders'; +import {MercatorProjection} from './mercator'; +import {ProjectionErrorMeasurement} from './globe_projection_error_measurement'; +import {createTileMeshWithBuffers, CreateTileMeshOptions} from '../../util/create_tile_mesh'; + +export const globeConstants = { + /** + * The size of border region for stencil masks, in internal tile coordinates. + * Used for globe rendering. + */ + globeTransitionTimeSeconds: 0.5, + zoomTransitionTimeSeconds: 0.5, + maxGlobeZoom: 12.0, + errorTransitionTimeSeconds: 0.5 +}; + +const granularitySettingsGlobe: SubdivisionGranularitySetting = new SubdivisionGranularitySetting({ + fill: new SubdivisionGranularityExpression(128, 1), + line: new SubdivisionGranularityExpression(512, 1), + // Always keep at least some subdivision on raster tiles, etc, + // otherwise they will be visibly warped at high zooms (before mercator transition). + // This si not needed on fill, because fill geometry tends to already be + // highly tessellated and granular at high zooms. + // Minimal granularity of 8 seems to be enough to avoid warped raster tiles, while also minimizing triangle count. + tile: new SubdivisionGranularityExpression(128, 32), + stencil: new SubdivisionGranularityExpression(128, 4), + circle: 3 +}); + +export class GlobeProjection implements Projection { + private _mercator: MercatorProjection; + + private _tileMeshCache: {[_: string]: Mesh} = {}; + + /** + * Stores whether globe rendering should be used. + * The value is injected from GlobeTransform. + */ + private _useGlobeRendering: boolean = true; + + // GPU atan() error correction + private _errorMeasurement: ProjectionErrorMeasurement; + private _errorQueryLatitudeDegrees: number; + private _errorCorrectionUsable: number = 0.0; + private _errorMeasurementLastValue: number = 0.0; + private _errorCorrectionPreviousValue: number = 0.0; + private _errorMeasurementLastChangeTime: number = -1000.0; + + get name(): 'globe' { + 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._useGlobeRendering; + } + + /** + * @internal + * Intended for internal use, only called from GlobeTransform. + */ + set useGlobeRendering(value: boolean) { + this._useGlobeRendering = value; + } + + get useSubdivision(): 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; + } + + get subdivisionGranularity(): SubdivisionGranularitySetting { + return granularitySettingsGlobe; + } + + get useGlobeControls(): boolean { + return this._useGlobeRendering; + } + + get errorQueryLatitudeDegrees(): number { return this._errorQueryLatitudeDegrees; } + + /** + * @internal + * Intended for internal use, only called from GlobeTransform. + */ + set errorQueryLatitudeDegrees(value: number) { + this._errorQueryLatitudeDegrees = value; + } + + /** + * @internal + * Globe projection periodically measures the error of the GPU's + * projection from mercator to globe and computes how much to correct + * the globe's latitude alignment. + * This stores the correction that should be applied to the projection matrix. + */ + get latitudeErrorCorrectionRadians(): number { return this._errorCorrectionUsable; } + + constructor() { + this._mercator = new MercatorProjection(); + } + + public destroy() { + if (this._errorMeasurement) { + this._errorMeasurement.destroy(); + } + } + + public isRenderingDirty(): boolean { + const now = browser.now(); + let dirty = false; + // Error correction transition + dirty = dirty || (now - this._errorMeasurementLastChangeTime) / 1000.0 < (globeConstants.errorTransitionTimeSeconds + 0.2); + // Error correction query in flight + dirty = dirty || (this._errorMeasurement && this._errorMeasurement.awaitingQuery); + return dirty; + } + + public updateGPUdependent(renderContext: ProjectionGPUContext): void { + this._mercator.updateGPUdependent(renderContext); + if (!this._errorMeasurement) { + this._errorMeasurement = new ProjectionErrorMeasurement(renderContext); + } + 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 / globeConstants.errorTransitionTimeSeconds, 0.0), 1.0); + const newCorrection = -this._errorMeasurementLastValue; // Note the negation + this._errorCorrectionUsable = lerp(this._errorCorrectionPreviousValue, newCorrection, easeCubicInOut(mix)); + } + + private _getMeshKey(options: CreateTileMeshOptions): string { + return `${options.granularity.toString(36)}_${options.generateBorders ? 'b' : ''}${options.extendToNorthPole ? 'n' : ''}${options.extendToSouthPole ? 's' : ''}`; + } + + public getMeshFromTileID(context: Context, canonical: CanonicalTileID, hasBorder: boolean, allowPoles: boolean, usage: TileMeshUsage): Mesh { + // Stencil granularity must match fill granularity + const granularityConfig = usage === 'stencil' ? granularitySettingsGlobe.stencil : granularitySettingsGlobe.tile; + const granularity = granularityConfig.getGranularityForZoomLevel(canonical.z); + const north = (canonical.y === 0) && allowPoles; + const south = (canonical.y === (1 << canonical.z) - 1) && allowPoles; + return this._getMesh(context, { + granularity, + generateBorders: hasBorder, + extendToNorthPole: north, + extendToSouthPole: south, + }); + } + + private _getMesh(context: Context, options: CreateTileMeshOptions): Mesh { + const key = this._getMeshKey(options); + + if (key in this._tileMeshCache) { + return this._tileMeshCache[key]; + } + + const mesh = createTileMeshWithBuffers(context, options); + this._tileMeshCache[key] = mesh; + return mesh; + } +} diff --git a/src/geo/projection/globe_camera_helper.ts b/src/geo/projection/globe_camera_helper.ts new file mode 100644 index 0000000000..dd67b8fa88 --- /dev/null +++ b/src/geo/projection/globe_camera_helper.ts @@ -0,0 +1,483 @@ +import Point from '@mapbox/point-geometry'; +import {IReadonlyTransform, ITransform} from '../transform_interface'; +import {cameraBoundsWarning, CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas} from './camera_helper'; +import {GlobeProjection} from './globe'; +import {LngLat, LngLatLike} from '../lng_lat'; +import {MercatorCameraHelper} from './mercator_camera_helper'; +import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment, globeDistanceOfLocationsPixels, interpolateLngLatForGlobe} from './globe_utils'; +import {clamp, createVec3f64, differenceOfAnglesDegrees, remapSaturate, warnOnce} from '../../util/util'; +import {mat4, vec3} from 'gl-matrix'; +import {MAX_VALID_LATITUDE, normalizeCenter, scaleZoom, zoomScale} from '../transform_helper'; +import {CameraForBoundsOptions} from '../../ui/camera'; +import {LngLatBounds} from '../lng_lat_bounds'; +import {PaddingOptions} from '../edge_insets'; +import {interpolates} from '@maplibre/maplibre-gl-style-spec'; + +/** + * @internal + */ +export class GlobeCameraHelper implements ICameraHelper { + private _globe: GlobeProjection; + private _mercatorCameraHelper: MercatorCameraHelper; + + constructor(globe: GlobeProjection) { + this._globe = globe; + this._mercatorCameraHelper = new MercatorCameraHelper(); + } + + get useGlobeControls(): boolean { return this._globe.useGlobeRendering; } + + handlePanInertia(pan: Point, transform: IReadonlyTransform): { + easingCenter: LngLat; + easingOffset: Point; + } { + if (!this.useGlobeControls) { + return this._mercatorCameraHelper.handlePanInertia(pan, transform); + } + + const panCenter = computeGlobePanCenter(pan, transform); + if (Math.abs(panCenter.lng - transform.center.lng) > 180) { + // If easeTo target would be over 180° distant, the animation would move + // in the opposite direction that what the user intended. + // Thus we clamp the movement to 179.5°. + panCenter.lng = transform.center.lng + 179.5 * Math.sign(panCenter.lng - transform.center.lng); + } + return { + easingCenter: panCenter, + easingOffset: new Point(0, 0), + }; + } + + handleMapControlsPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { + if (!this.useGlobeControls) { + this._mercatorCameraHelper.handleMapControlsPitchBearingZoom(deltas, tr); + return; + } + + const zoomPixel = deltas.around; + const zoomLoc = tr.screenPointToLocation(zoomPixel); + + if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta); + if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta); + const oldZoomPreZoomDelta = tr.zoom; + if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta); + const actualZoomDelta = tr.zoom - oldZoomPreZoomDelta; + + if (actualZoomDelta === 0) { + return; + } + + // Problem: `setLocationAtPoint` for globe works when it is called a single time, but is a little glitchy in practice when used repeatedly for zooming. + // - `setLocationAtPoint` repeatedly called at a location behind a pole will eventually glitch out + // - `setLocationAtPoint` at location the longitude of which is more than 90° different from current center will eventually glitch out + // But otherwise works fine at higher zooms, or when the target is somewhat near the current map center. + // Solution: use a heuristic zooming in the problematic cases and interpolate to `setLocationAtPoint` when possible. + + // Magic numbers that control: + // - when zoom movement slowing starts for cursor not on globe (avoid unnatural map movements) + // - when we interpolate from exact zooming to heuristic zooming based on longitude difference of target location to current center + // - when we interpolate from exact zooming to heuristic zooming based on globe being too small on screen + // - when zoom movement slowing starts for globe being too small on viewport (avoids unnatural/unwanted map movements when map is zoomed out a lot) + const raySurfaceDistanceForSlowingStart = 0.3; // Zoom movement slowing will start when the planet surface to ray distance is greater than this number (globe radius is 1, so 0.3 is ~2000km form the surface). + const slowingMultiplier = 0.5; // The lower this value, the slower will the "zoom movement slowing" occur. + const interpolateToHeuristicStartLng = 45; // When zoom location longitude is this many degrees away from map center, we start interpolating from exact zooming to heuristic zooming. + const interpolateToHeuristicEndLng = 85; // Longitude difference at which interpolation to heuristic zooming ends. + const interpolateToHeuristicExponent = 0.25; // Makes interpolation smoother. + const interpolateToHeuristicStartRadius = 0.75; // When globe is this many times larger than the smaller viewport dimension, we start interpolating from exact zooming to heuristic zooming. + const interpolateToHeuristicEndRadius = 0.35; // Globe size at which interpolation to heuristic zooming ends. + const slowingRadiusStart = 0.9; // If globe is this many times larger than the smaller viewport dimension, start inhibiting map movement while zooming + const slowingRadiusStop = 0.5; + const slowingRadiusSlowFactor = 0.25; // How much is movement slowed when globe is too small + + const dLngRaw = differenceOfAnglesDegrees(tr.center.lng, zoomLoc.lng); + const dLng = dLngRaw / (Math.abs(dLngRaw / 180) + 1.0); // This gradually reduces the amount of longitude change if the zoom location is very far, eg. on the other side of the pole (possible when looking at a pole). + const dLat = differenceOfAnglesDegrees(tr.center.lat, zoomLoc.lat); + + // Slow zoom movement down if the mouse ray is far from the planet. + const rayDirection = tr.getRayDirectionFromPixel(zoomPixel); + const rayOrigin = tr.cameraPosition; + const distanceToClosestPoint = vec3.dot(rayOrigin, rayDirection) * -1; // Globe center relative to ray origin is equal to -rayOrigin and rayDirection is normalized, thus we want to compute dot(-rayOrigin, rayDirection). + const closestPoint = createVec3f64(); + vec3.add(closestPoint, rayOrigin, [ + rayDirection[0] * distanceToClosestPoint, + rayDirection[1] * distanceToClosestPoint, + rayDirection[2] * distanceToClosestPoint + ]); + const distanceFromSurface = vec3.length(closestPoint) - 1; + const distanceFactor = Math.exp(-Math.max(distanceFromSurface - raySurfaceDistanceForSlowingStart, 0) * slowingMultiplier); + + // Slow zoom movement down if the globe is too small on viewport + const radius = getGlobeRadiusPixels(tr.worldSize, tr.center.lat) / Math.min(tr.width, tr.height); // Radius relative to larger viewport dimension + const radiusFactor = remapSaturate(radius, slowingRadiusStart, slowingRadiusStop, 1.0, slowingRadiusSlowFactor); + + // Compute how much to move towards the zoom location + const factor = (1.0 - zoomScale(-actualZoomDelta)) * Math.min(distanceFactor, radiusFactor); + + const oldCenterLat = tr.center.lat; + const oldZoom = tr.zoom; + const heuristicCenter = new LngLat( + tr.center.lng + dLng * factor, + clamp(tr.center.lat + dLat * factor, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE) + ); + + // Now compute the map center exact zoom + tr.setLocationAtPoint(zoomLoc, zoomPixel); + const exactCenter = tr.center; + + // Interpolate between exact zooming and heuristic zooming depending on the longitude difference between current center and zoom location. + const interpolationFactorLongitude = remapSaturate(Math.abs(dLngRaw), interpolateToHeuristicStartLng, interpolateToHeuristicEndLng, 0, 1); + const interpolationFactorRadius = remapSaturate(radius, interpolateToHeuristicStartRadius, interpolateToHeuristicEndRadius, 0, 1); + const heuristicFactor = Math.pow(Math.max(interpolationFactorLongitude, interpolationFactorRadius), interpolateToHeuristicExponent); + + const lngExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lng, heuristicCenter.lng); + const latExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lat, heuristicCenter.lat); + + tr.setCenter(new LngLat( + exactCenter.lng + lngExactToHeuristic * heuristicFactor, + exactCenter.lat + latExactToHeuristic * heuristicFactor + ).wrap()); + tr.setZoom(oldZoom + getZoomAdjustment(oldCenterLat, tr.center.lat)); + } + + handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void { + if (!this.useGlobeControls) { + this._mercatorCameraHelper.handleMapControlsPan(deltas, tr, preZoomAroundLoc); + return; + } + + if (!deltas.panDelta) { + return; + } + + // These are actually very similar to mercator controls, and should converge to them at high zooms. + // We avoid using the "grab a place and move it around" approach from mercator here, + // since it is not a very pleasant way to pan a globe. + const oldLat = tr.center.lat; + const oldZoom = tr.zoom; + tr.setCenter(computeGlobePanCenter(deltas.panDelta, tr).wrap()); + // Setting the center might adjust zoom to keep globe size constant, we need to avoid adding this adjustment a second time + tr.setZoom(oldZoom + getZoomAdjustment(oldLat, tr.center.lat)); + } + + cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult { + const result = this._mercatorCameraHelper.cameraForBoxAndBearing(options, padding, bounds, bearing, tr); + + if (!this.useGlobeControls) { + return result; + } + + // If globe is enabled, we use the parameters computed for mercator, and just update the zoom to fit the bounds. + + // Get clip space bounds including padding + const xLeft = (padding.left) / tr.width * 2.0 - 1.0; + const xRight = (tr.width - padding.right) / tr.width * 2.0 - 1.0; + const yTop = (padding.top) / tr.height * -2.0 + 1.0; + const yBottom = (tr.height - padding.bottom) / tr.height * -2.0 + 1.0; + + // Get camera bounds + const flipEastWest = differenceOfAnglesDegrees(bounds.getWest(), bounds.getEast()) < 0; + const lngWest = flipEastWest ? bounds.getEast() : bounds.getWest(); + const lngEast = flipEastWest ? bounds.getWest() : bounds.getEast(); + + const latNorth = Math.max(bounds.getNorth(), bounds.getSouth()); // "getNorth" doesn't always return north... + const latSouth = Math.min(bounds.getNorth(), bounds.getSouth()); + + // Additional vectors will be tested for the rectangle midpoints + const lngMid = lngWest + differenceOfAnglesDegrees(lngWest, lngEast) * 0.5; + const latMid = latNorth + differenceOfAnglesDegrees(latNorth, latSouth) * 0.5; + + // Obtain a globe projection matrix that does not include pitch (unsupported) + const clonedTr = tr.clone(); + clonedTr.setCenter(result.center); + clonedTr.setBearing(result.bearing); + clonedTr.setPitch(0); + clonedTr.setZoom(result.zoom); + const matrix = clonedTr.modelViewProjectionMatrix; + + // Vectors to test - the bounds' corners and edge midpoints + const testVectors = [ + angularCoordinatesToSurfaceVector(bounds.getNorthWest()), + angularCoordinatesToSurfaceVector(bounds.getNorthEast()), + angularCoordinatesToSurfaceVector(bounds.getSouthWest()), + angularCoordinatesToSurfaceVector(bounds.getSouthEast()), + // Also test edge midpoints + angularCoordinatesToSurfaceVector(new LngLat(lngEast, latMid)), + angularCoordinatesToSurfaceVector(new LngLat(lngWest, latMid)), + angularCoordinatesToSurfaceVector(new LngLat(lngMid, latNorth)), + angularCoordinatesToSurfaceVector(new LngLat(lngMid, latSouth)) + ]; + const vecToCenter = angularCoordinatesToSurfaceVector(result.center); + + // Test each vector, measure how much to scale down the globe to satisfy all tested points that they are inside clip space. + let smallestNeededScale = Number.POSITIVE_INFINITY; + for (const vec of testVectors) { + if (xLeft < 0) + smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xLeft)); + if (xRight > 0) + smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xRight)); + if (yTop > 0) + smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yTop)); + if (yBottom < 0) + smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yBottom)); + } + + if (!Number.isFinite(smallestNeededScale) || smallestNeededScale === 0) { + cameraBoundsWarning(); + return undefined; + } + + // Compute target zoom from the obtained scale. + result.zoom = clonedTr.zoom + scaleZoom(smallestNeededScale); + return result; + } + + /** + * Handles the zoom and center change during camera jumpTo. + */ + handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void { + if (!this.useGlobeControls) { + this._mercatorCameraHelper.handleJumpToCenterZoom(tr, options); + return; + } + + // Special zoom & center handling for globe: + // Globe constrained center isn't dependent on zoom level + const startingLat = tr.center.lat; + const constrainedCenter = tr.getConstrained(options.center ? LngLat.convert(options.center) : tr.center, tr.zoom).center; + tr.setCenter(constrainedCenter.wrap()); + + // Make sure to compute correct target zoom level if no zoom is specified + const targetZoom = (typeof options.zoom !== 'undefined') ? +options.zoom : (tr.zoom + getZoomAdjustment(startingLat, constrainedCenter.lat)); + if (tr.zoom !== targetZoom) { + tr.setZoom(targetZoom); + } + } + + handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult { + if (!this.useGlobeControls) { + return this._mercatorCameraHelper.handleEaseTo(tr, options); + } + + const startZoom = tr.zoom; + const startBearing = tr.bearing; + const startPitch = tr.pitch; + const startCenter = tr.center; + + const optionsZoom = typeof options.zoom !== 'undefined'; + + const doPadding = !tr.isPaddingEqual(options.padding); + + let isZooming = false; + + // Globe needs special handling for how zoom should be animated. + // 1) if zoom is set, ease to the given mercator zoom + // 2) if neither is set, assume constant apparent zoom (constant planet size) is to be kept + const preConstrainCenter = options.center ? + LngLat.convert(options.center) : + startCenter; + const constrainedCenter = tr.getConstrained( + preConstrainCenter, + startZoom // zoom can be whatever at this stage, it should not affect anything if globe is enabled + ).center; + normalizeCenter(tr, constrainedCenter); + + const clonedTr = tr.clone(); + clonedTr.setCenter(constrainedCenter); + if (doPadding) { + clonedTr.setPadding(options.padding); + } + clonedTr.setZoom(optionsZoom ? + +options.zoom : + startZoom + getZoomAdjustment(startCenter.lat, preConstrainCenter.lat)); + clonedTr.setBearing(options.bearing); + const clampedPoint = new Point( + clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), + clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) + ); + clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); + // Find final animation targets + const endCenterWithShift = (options.offset && options.offsetAsPoint.mag()) > 0 ? clonedTr.center : constrainedCenter; + const endZoomWithShift = optionsZoom ? + +options.zoom : + startZoom + getZoomAdjustment(startCenter.lat, endCenterWithShift.lat); + + // Planet radius for a given zoom level differs according to latitude + // Convert zooms to what they would be at equator for the given planet radius + const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); + const normalizedEndZoom = endZoomWithShift + getZoomAdjustment(endCenterWithShift.lat, 0); + const deltaLng = differenceOfAnglesDegrees(startCenter.lng, endCenterWithShift.lng); + const deltaLat = differenceOfAnglesDegrees(startCenter.lat, endCenterWithShift.lat); + + const finalScale = zoomScale(normalizedEndZoom - normalizedStartZoom); + isZooming = (endZoomWithShift !== startZoom); + + const easeFunc = (k: number) => { + if (startBearing !== options.bearing) { + tr.setBearing(interpolates.number(startBearing, options.bearing, k)); + } + if (startPitch !== options.pitch) { + tr.setPitch(interpolates.number(startPitch, options.pitch, k)); + } + + if (options.around) { + warnOnce('Easing around a point is not supported under globe projection.'); + tr.setLocationAtPoint(options.around, options.aroundPoint); + } else { + const base = normalizedEndZoom > normalizedStartZoom ? + Math.min(2, finalScale) : + Math.max(0.5, finalScale); + const speedup = Math.pow(base, 1 - k); + const factor = k * speedup; + + // Spherical lerp might be used here instead, but that was tested and it leads to very weird paths when the interpolated arc gets near the poles. + // Instead we interpolate LngLat almost directly, but taking into account that + // one degree of longitude gets progressively smaller relative to latitude towards the poles. + const newCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, factor); + tr.setCenter(newCenter.wrap()); + } + + if (isZooming) { + const normalizedInterpolatedZoom = interpolates.number(normalizedStartZoom, normalizedEndZoom, k); + const interpolatedZoom = normalizedInterpolatedZoom + getZoomAdjustment(0, tr.center.lat); + tr.setZoom(interpolatedZoom); + } + }; + + return { + easeFunc, + isZooming, + elevationCenter: endCenterWithShift, + }; + } + + handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult { + if (!this.useGlobeControls) { + return this._mercatorCameraHelper.handleFlyTo(tr, options); + } + const optionsZoom = typeof options.zoom !== 'undefined'; + + const startCenter = tr.center; + const startZoom = tr.zoom; + const doPadding = !tr.isPaddingEqual(options.padding); + + // Obtain target center and zoom + const constrainedCenter = tr.getConstrained( + LngLat.convert(options.center || options.locationAtOffset), + startZoom + ).center; + const targetZoom = optionsZoom ? +options.zoom : tr.zoom + getZoomAdjustment(tr.center.lat, constrainedCenter.lat); + + // Compute target center that respects offset by creating a temporary transform and calling its `setLocationAtPoint`. + const clonedTr = tr.clone(); + clonedTr.setCenter(constrainedCenter); + if (doPadding) { + clonedTr.setPadding(options.padding as PaddingOptions); + } + clonedTr.setZoom(targetZoom); + clonedTr.setBearing(options.bearing); + const clampedPoint = new Point( + clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), + clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) + ); + clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); + const targetCenter = clonedTr.center; + + normalizeCenter(tr, targetCenter); + + const pixelPathLength = globeDistanceOfLocationsPixels(tr, startCenter, targetCenter); + + const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); + const normalizedTargetZoom = targetZoom + getZoomAdjustment(targetCenter.lat, 0); + const scaleOfZoom = zoomScale(normalizedTargetZoom - normalizedStartZoom); + + const optionsMinZoom = typeof options.minZoom === 'number'; + + let scaleOfMinZoom: number; + + if (optionsMinZoom) { + const normalizedOptionsMinZoom = +options.minZoom + getZoomAdjustment(targetCenter.lat, 0); + const normalizedMinZoomPreConstrain = Math.min(normalizedOptionsMinZoom, normalizedStartZoom, normalizedTargetZoom); + const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat); + const minZoom = tr.getConstrained(targetCenter, minZoomPreConstrain).zoom; + const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0); + scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom); + } + + const deltaLng = differenceOfAnglesDegrees(startCenter.lng, targetCenter.lng); + const deltaLat = differenceOfAnglesDegrees(startCenter.lat, targetCenter.lat); + + const easeFunc = (k: number, scale: number, centerFactor: number, _pointAtOffset: Point) => { + const interpolatedCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, centerFactor); + + const newCenter = k === 1 ? targetCenter : interpolatedCenter; + tr.setCenter(newCenter.wrap()); + + const interpolatedZoom = normalizedStartZoom + scaleZoom(scale); + tr.setZoom(k === 1 ? targetZoom : (interpolatedZoom + getZoomAdjustment(0, newCenter.lat))); + }; + + return { + easeFunc, + scaleOfZoom, + targetCenter, + scaleOfMinZoom, + pixelPathLength, + }; + } + + /** + * Computes how much to scale the globe in order for a given point on its surface (a location) to project to a given clip space coordinate in either the X or the Y axis. + * @param vector - Position of the queried location on the surface of the unit sphere globe. + * @param toCenter - Position of current transform center on the surface of the unit sphere globe. + * This is needed because zooming the globe not only changes its scale, + * but also moves the camera closer or further away along this vector (pitch is disregarded). + * @param projection - The globe projection matrix. + * @param targetDimension - The dimension in which the scaled vector must match the target value in clip space. + * @param targetValue - The target clip space value in the specified dimension to which the queried vector must project. + * @returns How much to scale the globe. + */ + private static solveVectorScale(vector: vec3, toCenter: vec3, projection: mat4, targetDimension: 'x' | 'y', targetValue: number): number | null { + // We want to compute how much to scale the sphere in order for the input `vector` to project to `targetValue` in the given `targetDimension` (X or Y). + const k = targetValue; + const columnXorY = targetDimension === 'x' ? + [projection[0], projection[4], projection[8], projection[12]] : // X + [projection[1], projection[5], projection[9], projection[13]]; // Y + const columnZ = [projection[3], projection[7], projection[11], projection[15]]; + + const vecDotXY = vector[0] * columnXorY[0] + vector[1] * columnXorY[1] + vector[2] * columnXorY[2]; + const vecDotZ = vector[0] * columnZ[0] + vector[1] * columnZ[1] + vector[2] * columnZ[2]; + const toCenterDotXY = toCenter[0] * columnXorY[0] + toCenter[1] * columnXorY[1] + toCenter[2] * columnXorY[2]; + const toCenterDotZ = toCenter[0] * columnZ[0] + toCenter[1] * columnZ[1] + toCenter[2] * columnZ[2]; + + // The following can be derived from writing down what happens to a vector scaled by a parameter ("V * t") when it is multiplied by a projection matrix, then solving for "t". + // Or rather, we derive it for a vector "V * t + (1-t) * C". Where V is `vector` and C is `toCenter`. The extra addition is needed because zooming out also moves the camera along "C". + + const t = (toCenterDotXY + columnXorY[3] - k * toCenterDotZ - k * columnZ[3]) / (toCenterDotXY - vecDotXY - k * toCenterDotZ + k * vecDotZ); + + if ( + toCenterDotXY + k * vecDotZ === vecDotXY + k * toCenterDotZ || + columnZ[3] * (vecDotXY - toCenterDotXY) + columnXorY[3] * (toCenterDotZ - vecDotZ) + vecDotXY * toCenterDotZ === toCenterDotXY * vecDotZ + ) { + // The computed result is invalid. + return null; + } + return t; + } + + /** + * Returns `newValue` if it is: + * + * - not null AND + * - not negative AND + * - smaller than `newValue`, + * + * ...otherwise returns `oldValue`. + */ + private static getLesserNonNegativeNonNull(oldValue: number, newValue: number): number { + if (newValue !== null && newValue >= 0 && newValue < oldValue) { + return newValue; + } else { + return oldValue; + } + } +} diff --git a/src/geo/projection/globe_covering_tiles.test.ts b/src/geo/projection/globe_covering_tiles.test.ts new file mode 100644 index 0000000000..c49e95bbc7 --- /dev/null +++ b/src/geo/projection/globe_covering_tiles.test.ts @@ -0,0 +1,35 @@ +import {Aabb} from '../../util/primitives'; +import {expectToBeCloseToArray} from '../../util/test/util'; +import {getTileAABB} from './globe_covering_tiles'; + +describe('aabb', () => { + test('z=0', () => { + const aabb = getTileAABB(0, 0, 0); + expect(aabb).toEqual(new Aabb( + [-1, -1, -1], + [1, 1, 1], + )); + }); + + test('z=1,x=0', () => { + const aabb = getTileAABB(0, 0, 1); + expect(aabb).toEqual(new Aabb( + [-1, 0, -1], + [0, 1, 1], + )); + }); + + test('z=1,x=1', () => { + const aabb = getTileAABB(1, 0, 1); + expect(aabb).toEqual(new Aabb( + [0, 0, -1], + [1, 1, 1], + )); + }); + + test('z=2,x=1', () => { + const aabb = getTileAABB(1, 0, 2); + expectToBeCloseToArray([...aabb.min], [-0.3985368153383868, 0.9171523356672743, -7.321002528698027e-17,]); + expectToBeCloseToArray([...aabb.max], [0, 1, 0.3985368153383868]); + }); +}); diff --git a/src/geo/projection/globe_covering_tiles.ts b/src/geo/projection/globe_covering_tiles.ts new file mode 100644 index 0000000000..dcd3824fe1 --- /dev/null +++ b/src/geo/projection/globe_covering_tiles.ts @@ -0,0 +1,306 @@ +import {vec2, vec3, vec4} from 'gl-matrix'; +import {Aabb, Frustum, IntersectionResult} from '../../util/primitives'; +import {CoveringTilesOptions} from '../transform_interface'; +import {OverscaledTileID} from '../../source/tile_id'; +import {MercatorCoordinate} from '../mercator_coordinate'; +import {EXTENT} from '../../data/extent'; +import {projectTileCoordinatesToSphere} from './globe_utils'; + +type CoveringTilesResult = { + tileID: OverscaledTileID; + distanceSq: number; + tileDistanceToCamera: number; +}; + +type CoveringTilesStackEntry = { + x: number; + y: number; + zoom: number; + fullyVisible: boolean; +}; + +/** + * Computes distance of a point to a tile in an arbitrary axis. + * World is assumed to have size 1, distance returned is to the nearer tile edge. + * @param point - Point position. + * @param tile - Tile position. + * @param tileSize - Tile size. + */ +function distanceToTileSimple(point: number, tile: number, tileSize: number): number { + const delta = point - tile; + return (delta < 0) ? -delta : Math.max(0, delta - tileSize); +} + +function distanceToTileWrapX(pointX: number, pointY: number, tileCornerX: number, tileCornerY: number, tileSize: number): number { + const tileCornerToPointX = pointX - tileCornerX; + + let distanceX: number; + if (tileCornerToPointX < 0) { + // Point is left of tile + distanceX = Math.min(-tileCornerToPointX, 1.0 + tileCornerToPointX - tileSize); + } else if (tileCornerToPointX > 1) { + // Point is right of tile + distanceX = Math.min(Math.max(tileCornerToPointX - tileSize, 0), 1.0 - tileCornerToPointX); + } else { + // Point is inside tile in the X axis. + distanceX = 0; + } + + return Math.max(distanceX, distanceToTileSimple(pointY, tileCornerY, tileSize)); +} + +/** + * Returns the distance of a point to a square tile. If the point is inside the tile, returns 0. + * Assumes the world to be of size 1. + * Handles distances on a sphere correctly: X is wrapped when crossing the antimeridian, + * when crossing the poles Y is mirrored and X is shifted by half world size. + */ +function distanceToTile(pointX: number, pointY: number, tileCornerX: number, tileCornerY: number, tileSize: number): number { + const worldSize = 1.0; + const halfWorld = 0.5 * worldSize; + let smallestDistance = 2.0 * worldSize; + // Original tile + smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX, tileCornerY, tileSize)); + // Up + smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX + halfWorld, -tileCornerY - tileSize, tileSize)); + // Down + smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX + halfWorld, worldSize + worldSize - tileCornerY - tileSize, tileSize)); + + return smallestDistance; +} + +function shouldSplitTile(centerCoord: MercatorCoordinate, cameraCoord: MercatorCoordinate, tileX: number, tileY: number, tileSize: number, radiusOfMaxLvlLodInTiles: number): boolean { + // Determine whether the tile needs any further splitting. + // At each level, we want at least `radiusOfMaxLvlLodInTiles` tiles loaded in each axis from the map center point. + // For radiusOfMaxLvlLodInTiles=1, this would result in something like this: + // z=4 |--------------||--------------||--------------| + // z=5 |------||------||------| + // z=6 |--||--||--| + // ^map center + // ...where "|--|" symbolizes a tile viewed sideways. + // This logic might be slightly different from what mercator_transform.ts does, but should result in very similar (if not the same) set of tiles being loaded. + const centerDist = distanceToTile(centerCoord.x, centerCoord.y, tileX, tileY, tileSize); + const cameraDist = distanceToTile(cameraCoord.x, cameraCoord.y, tileX, tileY, tileSize); + return Math.min(centerDist, cameraDist) * 2 <= radiusOfMaxLvlLodInTiles; // Multiply distance by 2, because the subdivided tiles would be half the size +} + +// Returns the wrap value for a given tile, computed so that tiles will remain loaded when crossing the antimeridian. +function getWrap(centerCoord: MercatorCoordinate, tileX: number, tileSize: number): number { + const distanceCurrent = distanceToTileSimple(centerCoord.x, tileX, tileSize); + const distanceLeft = distanceToTileSimple(centerCoord.x, tileX - 1.0, tileSize); + const distanceRight = distanceToTileSimple(centerCoord.x, tileX + 1.0, tileSize); + const distanceSmallest = Math.min(distanceCurrent, distanceLeft, distanceRight); + if (distanceSmallest === distanceRight) { + return 1; + } + if (distanceSmallest === distanceLeft) { + return -1; + } + return 0; +} + +// Tile AABBs are static, cache them! +const tileAabbCache: Map = new Map(); + +/** + * Returns the AABB of the specified tile. The AABB is in the coordinate space where the globe is a unit sphere. + * @param tileID - Tile x, y and z for zoom. + */ +export function getTileAABB(tileIdX: number, tileIdY: number, tileIdZ: number): Aabb { + const key = `${tileIdX}_${tileIdY}_${tileIdZ}`; + if (tileAabbCache.has(key)) { + return tileAabbCache.get(key); + } + + let aabb: Aabb; + // We can get away with only checking the 4 tile corners for AABB construction, because for any tile of zoom level 2 or higher + // it holds that the extremes (minimal or maximal value) of X, Y or Z coordinates must lie in one of the tile corners. + // + // To see why this holds, consider the formula for computing X,Y and Z from angular coordinates. + // It goes something like this: + // + // X = sin(lng) * cos(lat) + // Y = sin(lat) + // Z = cos(lng) * cos(lat) + // + // Note that a tile always covers a continuous range of lng and lat values, + // and that tiles that border the mercator north/south edge are assumed to extend all the way to the poles. + // + // We will consider each coordinate separately and show that an extreme must always lie in a tile corner for every axis, and must not lie inside the tile. + // + // For Y, it is clear that the only way for an extreme to not lie on an edge of the lat range is for the range to contain lat=90° or lat=-90° without either being the tile edge. + // This cannot happen for any tile, these latitudes will always: + // - either lie outside the tile entirely, thus Y will be monotonically increasing or decreasing across the entire tile, thus the extreme must lie at a corner/edge + // - or be the tile edge itself, thus the extreme will lie at the tile edge + // + // For X, considering only longitude, the tile would also have to contain lng=90° or lng=-90° (with neither being the tile edge) for the extreme to not lie on a tile edge. + // This can only happen at zoom levels 0 and 1, which are handled separately. + // But X is also scaled by cos(lat)! However, this can only cause an extreme to lie inside the tile if the tile crosses lat=0°, which cannot happen for zoom levels other than 0. + // + // For Z, similarly to X, the extremes must lie at lng=0° or lng=180°, but for zoom levels other than 0 these cannot lie inside the tile. Scaling by cos(lat) has the same effect as with the X axis. + // + // So checking the 4 tile corners only fails for tiles with zoom level <2, and these are handled separately with hardcoded AABBs: + // - zoom level 0 tile is the entire sphere + // - zoom level 1 tiles are "quarters of a sphere" + + if (tileIdZ <= 0) { + // Tile covers the entire sphere. + aabb = new Aabb( + [-1, -1, -1], + [1, 1, 1] + ); + } else if (tileIdZ === 1) { + // Tile covers a quarter of the sphere. + // X is 1 at lng=E90° + // Y is 1 at **north** pole + // Z is 1 at null island + aabb = new Aabb( + [tileIdX === 0 ? -1 : 0, tileIdY === 0 ? 0 : -1, -1], + [tileIdX === 0 ? 0 : 1, tileIdY === 0 ? 1 : 0, 1] + ); + } else { + // Compute AABB using the 4 corners. + + const corners = [ + projectTileCoordinatesToSphere(0, 0, tileIdX, tileIdY, tileIdZ), + projectTileCoordinatesToSphere(EXTENT, 0, tileIdX, tileIdY, tileIdZ), + projectTileCoordinatesToSphere(EXTENT, EXTENT, tileIdX, tileIdY, tileIdZ), + projectTileCoordinatesToSphere(0, EXTENT, tileIdX, tileIdY, tileIdZ), + ]; + + const min: vec3 = [1, 1, 1]; + const max: vec3 = [-1, -1, -1]; + + for (const c of corners) { + for (let i = 0; i < 3; i++) { + min[i] = Math.min(min[i], c[i]); + max[i] = Math.max(max[i], c[i]); + } + } + + // Special handling of poles - we need to extend the tile AABB + // to include the pole for tiles that border mercator north/south edge. + if (tileIdY === 0 || (tileIdY === (1 << tileIdZ) - 1)) { + const pole = [0, tileIdY === 0 ? 1 : -1, 0]; + for (let i = 0; i < 3; i++) { + min[i] = Math.min(min[i], pole[i]); + max[i] = Math.max(max[i], pole[i]); + } + } + + aabb = new Aabb( + min, + max + ); + } + + tileAabbCache.set(key, aabb); + return aabb; +} + +/** + * A simple/heuristic function that returns whether the tile is visible under the current transform. + * @returns 0 is not visible, 1 if partially visible, 2 if fully visible. + */ +function isTileVisible(frustum: Frustum, plane: vec4, x: number, y: number, z: number): IntersectionResult { + const aabb = getTileAABB(x, y, z); + + const frustumTest = aabb.intersectsFrustum(frustum); + const planeTest = aabb.intersectsPlane(plane); + + if (frustumTest === IntersectionResult.None || planeTest === IntersectionResult.None) { + return IntersectionResult.None; + } + + if (frustumTest === IntersectionResult.Full && planeTest === IntersectionResult.Full) { + return IntersectionResult.Full; + } + + return IntersectionResult.Partial; +} + +/** + * Returns a list of tiles that optimally covers the screen. Adapted for globe projection. + * Correctly handles LOD when moving over the antimeridian. + * @param transform - The globe transform instance. + * @param options - Additional coveringTiles options. + * @returns A list of tile coordinates, ordered by ascending distance from camera. + */ +export function globeCoveringTiles(frustum: Frustum, plane: vec4, cameraCoord: MercatorCoordinate, centerCoord: MercatorCoordinate, coveringZoom: number, options: CoveringTilesOptions): OverscaledTileID[] { + let z = coveringZoom; + const actualZ = z; + + if (options.minzoom !== undefined && z < options.minzoom) { + return []; + } + if (options.maxzoom !== undefined && z > options.maxzoom) { + z = options.maxzoom; + } + + const numTiles = Math.pow(2, z); + const cameraPoint = [numTiles * cameraCoord.x, numTiles * cameraCoord.y, 0]; + const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0]; + + const radiusOfMaxLvlLodInTiles = 3; // Matches the value in the mercator variant of coveringTiles + + // Do a depth-first traversal to find visible tiles and proper levels of detail + const stack: Array = []; + const result: Array = []; + const maxZoom = z; + const overscaledZ = options.reparseOverscaled ? actualZ : z; + stack.push({ + zoom: 0, + x: 0, + y: 0, + fullyVisible: false + }); + + while (stack.length > 0) { + const it = stack.pop(); + const x = it.x; + const y = it.y; + let fullyVisible = it.fullyVisible; + + // Visibility of a tile is not required if any of its ancestor if fully visible + if (!fullyVisible) { + const intersectResult = isTileVisible(frustum, plane, it.x, it.y, it.zoom); + + if (intersectResult === IntersectionResult.None) + continue; + + fullyVisible = intersectResult === IntersectionResult.Full; + } + + const scale = 1 << (Math.max(it.zoom, 0)); + const tileSize = 1.0 / scale; + const tileX = x / scale; // In range 0..1 + const tileY = y / scale; // In range 0..1 + + const split = shouldSplitTile(centerCoord, cameraCoord, tileX, tileY, tileSize, radiusOfMaxLvlLodInTiles); + + // Have we reached the target depth or is the tile too far away to be any split further? + if (it.zoom === maxZoom || !split) { + const dz = maxZoom - it.zoom; + const dx = cameraPoint[0] - 0.5 - (x << dz); + const dy = cameraPoint[1] - 0.5 - (y << dz); + // We need to compute a valid wrap value for the tile to keep compatibility with mercator + const wrap = getWrap(centerCoord, tileX, tileSize); + result.push({ + tileID: new OverscaledTileID(it.zoom === maxZoom ? overscaledZ : it.zoom, wrap, it.zoom, x, y), + distanceSq: vec2.sqrLen([centerPoint[0] - 0.5 - dx, centerPoint[1] - 0.5 - dy]), + // this variable is currently not used, but may be important to reduce the amount of loaded tiles + tileDistanceToCamera: Math.sqrt(dx * dx + dy * dy) + }); + continue; + } + + for (let i = 0; i < 4; i++) { + const childX = (x << 1) + (i % 2); + const childY = (y << 1) + (i >> 1); + const childZ = it.zoom + 1; + stack.push({zoom: childZ, x: childX, y: childY, fullyVisible}); + } + } + + return result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID); +} 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/globe_transform.test.ts b/src/geo/projection/globe_transform.test.ts new file mode 100644 index 0000000000..fdd264559e --- /dev/null +++ b/src/geo/projection/globe_transform.test.ts @@ -0,0 +1,734 @@ +import {GlobeProjection} from './globe'; +import {EXTENT} from '../../data/extent'; +import Point from '@mapbox/point-geometry'; +import {LngLat} from '../lng_lat'; +import {GlobeTransform} from './globe_transform'; +import {CanonicalTileID, OverscaledTileID, UnwrappedTileID} from '../../source/tile_id'; +import {angularCoordinatesRadiansToVector, mercatorCoordinatesToAngularCoordinatesRadians, sphereSurfacePointToCoordinates} from './globe_utils'; +import {expectToBeCloseToArray, getGlobeProjectionMock, sleep} from '../../util/test/util'; +import {MercatorCoordinate} from '../mercator_coordinate'; +import {tileCoordinatesToLocation} from './mercator_utils'; + +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 createGlobeTransform(globeProjection: GlobeProjection) { + const globeTransform = new GlobeTransform(globeProjection); + globeTransform.resize(640, 480); + globeTransform.setFov(45); + return globeTransform; +} + +describe('GlobeTransform', () => { + const globeProjectionMock = getGlobeProjectionMock(); + + describe('getProjectionData', () => { + const globeTransform = createGlobeTransform(globeProjectionMock); + test('mercator tile extents are set', () => { + const projectionData = globeTransform.getProjectionData(new OverscaledTileID(1, 0, 1, 1, 0)); + expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + }); + }); + + describe('clipping plane', () => { + const globeTransform = createGlobeTransform(globeProjectionMock); + + describe('general plane properties', () => { + const projectionData = globeTransform.getProjectionData(new OverscaledTileID(0, 0, 0, 0, 0)); + + test('plane vector length', () => { + const len = Math.sqrt( + projectionData.clippingPlane[0] * projectionData.clippingPlane[0] + + projectionData.clippingPlane[1] * projectionData.clippingPlane[1] + + projectionData.clippingPlane[2] * projectionData.clippingPlane[2] + ); + expect(len).toBeCloseTo(0.25); + }); + + test('camera is in positive halfspace', () => { + expect(planeDistance(globeTransform.cameraPosition as [number, number, number], projectionData.clippingPlane)).toBeGreaterThan(0); + }); + + test('coordinates 0E,0N are in positive halfspace', () => { + expect(testPlaneAgainstLngLat(0, 0, projectionData.clippingPlane)).toBeGreaterThan(0); + }); + + test('coordinates 40E,0N are in positive halfspace', () => { + expect(testPlaneAgainstLngLat(40, 0, projectionData.clippingPlane)).toBeGreaterThan(0); + }); + + test('coordinates 0E,90N are in negative halfspace', () => { + expect(testPlaneAgainstLngLat(0, 90, projectionData.clippingPlane)).toBeLessThan(0); + }); + + test('coordinates 90E,0N are in negative halfspace', () => { + expect(testPlaneAgainstLngLat(90, 0, projectionData.clippingPlane)).toBeLessThan(0); + }); + + test('coordinates 180E,0N are in negative halfspace', () => { + expect(testPlaneAgainstLngLat(180, 0, projectionData.clippingPlane)).toBeLessThan(0); + }); + }); + }); + + describe('projection', () => { + test('mercator coordinate to sphere point', () => { + const precisionDigits = 10; + + let projectedAngles; + let projected; + + projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0.5); + expectToBeCloseToArray(projectedAngles, [0, 0], precisionDigits); + projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number]; + expectToBeCloseToArray(projected, [0, 0, 1], precisionDigits); + + projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0, 0.5); + expectToBeCloseToArray(projectedAngles, [Math.PI, 0], precisionDigits); + projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number]; + expectToBeCloseToArray(projected, [0, 0, -1], precisionDigits); + + projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.75, 0.5); + expectToBeCloseToArray(projectedAngles, [Math.PI / 2.0, 0], precisionDigits); + projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number]; + expectToBeCloseToArray(projected, [1, 0, 0], precisionDigits); + + projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0); + expectToBeCloseToArray(projectedAngles, [0, 1.4844222297453324], precisionDigits); // ~0.47pi + projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number]; + expectToBeCloseToArray(projected, [0, 0.99627207622075, 0.08626673833405434], precisionDigits); + }); + + test('camera position', () => { + const precisionDigits = 10; + + const globeTransform = createGlobeTransform(globeProjectionMock); + expectToBeCloseToArray(globeTransform.cameraPosition as Array, [0, 0, 8.110445867263898], precisionDigits); + + globeTransform.resize(512, 512); + globeTransform.setZoom(-0.5); + globeTransform.setCenter(new LngLat(0, 80)); + expectToBeCloseToArray(globeTransform.cameraPosition as Array, [0, 2.2818294674820794, 0.40234810049271963], precisionDigits); + + globeTransform.setPitch(35); + globeTransform.setBearing(70); + expectToBeCloseToArray(globeTransform.cameraPosition as Array, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits); + + globeTransform.setCenter(new LngLat(-10, 42)); + expectToBeCloseToArray(globeTransform.cameraPosition as Array, [-3.8450970996236364, 2.9368285470351516, 4.311953269048194], precisionDigits); + }); + + test('sphere point to coordinate', () => { + const precisionDigits = 10; + let unprojected = sphereSurfacePointToCoordinates([0, 0, 1]) as LngLat; + expect(unprojected.lng).toBeCloseTo(0, precisionDigits); + expect(unprojected.lat).toBeCloseTo(0, precisionDigits); + unprojected = sphereSurfacePointToCoordinates([0, 1, 0]) as LngLat; + expect(unprojected.lng).toBeCloseTo(0, precisionDigits); + expect(unprojected.lat).toBeCloseTo(90, precisionDigits); + unprojected = sphereSurfacePointToCoordinates([1, 0, 0]) as LngLat; + expect(unprojected.lng).toBeCloseTo(90, precisionDigits); + expect(unprojected.lat).toBeCloseTo(0, precisionDigits); + }); + + const screenCenter = new Point(640 / 2, 480 / 2); // We need the exact screen center + const screenTopEdgeCenter = new Point(640 / 2, 0); + + describe('project location to coordinates', () => { + const precisionDigits = 10; + const globeTransform = createGlobeTransform(globeProjectionMock); + + test('basic test', () => { + globeTransform.setCenter(new LngLat(0, 0)); + let projected = globeTransform.locationToScreenPoint(globeTransform.center); + expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits); + expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits); + + globeTransform.setCenter(new LngLat(70, 50)); + projected = globeTransform.locationToScreenPoint(globeTransform.center); + expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits); + expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits); + + globeTransform.setCenter(new LngLat(0, 84)); + projected = globeTransform.locationToScreenPoint(globeTransform.center); + expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits); + expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits); + }); + + test('project a location that is slightly above and below map\'s center point', () => { + globeTransform.setCenter(new LngLat(0, 0)); + let projected = globeTransform.locationToScreenPoint(new LngLat(0, 1)); + expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits); + expect(projected.y).toBeLessThan(screenCenter.y); + + projected = globeTransform.locationToScreenPoint(new LngLat(0, -1)); + expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits); + expect(projected.y).toBeGreaterThan(screenCenter.y); + }); + }); + + describe('unproject', () => { + test('unproject screen center', () => { + const precisionDigits = 10; + const globeTransform = createGlobeTransform(globeProjectionMock); + let unprojected = globeTransform.screenPointToLocation(screenCenter); + expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits); + + globeTransform.setCenter(new LngLat(90.0, 0.0)); + unprojected = globeTransform.screenPointToLocation(screenCenter); + expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits); + + globeTransform.setCenter(new LngLat(0.0, 60.0)); + unprojected = globeTransform.screenPointToLocation(screenCenter); + expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits); + }); + + test('unproject point to the side', () => { + const precisionDigits = 10; + const globeTransform = createGlobeTransform(globeProjectionMock); + let coords: LngLat; + let projected: Point; + let unprojected: LngLat; + + coords = new LngLat(0, 0); + projected = globeTransform.locationToScreenPoint(coords); + unprojected = globeTransform.screenPointToLocation(projected); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + + coords = new LngLat(10, 20); + projected = globeTransform.locationToScreenPoint(coords); + unprojected = globeTransform.screenPointToLocation(projected); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + + coords = new LngLat(15, -2); + projected = globeTransform.locationToScreenPoint(coords); + unprojected = globeTransform.screenPointToLocation(projected); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + }); + + test('unproject behind the pole', () => { + // This test tries to unproject a point that is beyond the north pole + // from the camera's point of view. + // This particular case turned out to be problematic, hence this test. + + const precisionDigits = 10; + const globeTransform = createGlobeTransform(globeProjectionMock); + // Transform settings from the render test projection/globe/fill-planet-pole + // See the expected result for how the globe should look with this transform. + globeTransform.resize(512, 512); + globeTransform.setZoom(-0.5); + globeTransform.setCenter(new LngLat(0, 80)); + + let coords: LngLat; + let projected: Point; + let unprojected: LngLat; + + coords = new LngLat(179.9, 71); + projected = globeTransform.locationToScreenPoint(coords); + unprojected = globeTransform.screenPointToLocation(projected); + expect(projected.x).toBeCloseTo(256.2434702034287, precisionDigits); + expect(projected.y).toBeCloseTo(48.27080146399297, precisionDigits); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + + // Near the pole + coords = new LngLat(179.9, 89.0); + projected = globeTransform.locationToScreenPoint(coords); + unprojected = globeTransform.screenPointToLocation(projected); + expect(projected.x).toBeCloseTo(256.0140972925064, precisionDigits); + expect(projected.y).toBeCloseTo(167.69159699932908, precisionDigits); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + }); + + test('unproject outside of sphere', () => { + const precisionDigits = 10; + const globeTransform = createGlobeTransform(globeProjectionMock); + // Try unprojection a point somewhere above the western horizon + globeTransform.setPitch(60); + globeTransform.setBearing(-90); + const unprojected = globeTransform.screenPointToLocation(screenTopEdgeCenter); + expect(unprojected.lng).toBeCloseTo(-34.699626794124015, precisionDigits); + expect(unprojected.lat).toBeCloseTo(0.0, precisionDigits); + }); + }); + + describe('setLocationAtPoint', () => { + const precisionDigits = 10; + const globeTransform = createGlobeTransform(globeProjectionMock); + globeTransform.setZoom(1); + let coords: LngLat; + let point: Point; + let projected: Point; + let unprojected: LngLat; + + test('identity', () => { + // Should do nothing + coords = new LngLat(0, 0); + point = new Point(320, 240); + globeTransform.setLocationAtPoint(coords, point); + unprojected = globeTransform.screenPointToLocation(point); + projected = globeTransform.locationToScreenPoint(coords); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + expect(projected.x).toBeCloseTo(point.x, precisionDigits); + expect(projected.y).toBeCloseTo(point.y, precisionDigits); + }); + + test('offset lnglat', () => { + coords = new LngLat(5, 10); + point = new Point(320, 240); + globeTransform.setLocationAtPoint(coords, point); + unprojected = globeTransform.screenPointToLocation(point); + projected = globeTransform.locationToScreenPoint(coords); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + expect(projected.x).toBeCloseTo(point.x, precisionDigits); + expect(projected.y).toBeCloseTo(point.y, precisionDigits); + }); + + test('offset pixel + lnglat', () => { + coords = new LngLat(5, 10); + point = new Point(330, 240); // 10 pixels to the right + globeTransform.setLocationAtPoint(coords, point); + unprojected = globeTransform.screenPointToLocation(point); + projected = globeTransform.locationToScreenPoint(coords); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + expect(projected.x).toBeCloseTo(point.x, precisionDigits); + expect(projected.y).toBeCloseTo(point.y, precisionDigits); + }); + + test('larger offset', () => { + coords = new LngLat(30, -2); + point = new Point(250, 180); + globeTransform.setLocationAtPoint(coords, point); + unprojected = globeTransform.screenPointToLocation(point); + projected = globeTransform.locationToScreenPoint(coords); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + expect(projected.x).toBeCloseTo(point.x, precisionDigits); + expect(projected.y).toBeCloseTo(point.y, precisionDigits); + }); + + describe('rotated', () => { + globeTransform.setBearing(90); + + test('identity', () => { + // Should do nothing + coords = new LngLat(0, 0); + point = new Point(320, 240); + globeTransform.setLocationAtPoint(coords, point); + unprojected = globeTransform.screenPointToLocation(point); + projected = globeTransform.locationToScreenPoint(coords); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + expect(projected.x).toBeCloseTo(point.x, precisionDigits); + expect(projected.y).toBeCloseTo(point.y, precisionDigits); + }); + test('offset lnglat', () => { + coords = new LngLat(5, 0); + point = new Point(320, 240); + globeTransform.setLocationAtPoint(coords, point); + unprojected = globeTransform.screenPointToLocation(point); + projected = globeTransform.locationToScreenPoint(coords); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + expect(projected.x).toBeCloseTo(point.x, precisionDigits); + expect(projected.y).toBeCloseTo(point.y, precisionDigits); + }); + test('offset pixel + lnglat', () => { + coords = new LngLat(0, 10); + point = new Point(350, 240); // 30 pixels to the right + globeTransform.setLocationAtPoint(coords, point); + unprojected = globeTransform.screenPointToLocation(point); + projected = globeTransform.locationToScreenPoint(coords); + expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits); + expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits); + expect(projected.x).toBeCloseTo(point.x, precisionDigits); + expect(projected.y).toBeCloseTo(point.y, precisionDigits); + expect(globeTransform.center.lat).toBeCloseTo(20.659450722109348, precisionDigits); + }); + }); + }); + }); + + describe('isPointOnMapSurface', () => { + const globeTransform = new GlobeTransform(globeProjectionMock); + globeTransform.resize(640, 480); + globeTransform.setZoom(1); + + test('Top screen edge', () => { + expect(globeTransform.isPointOnMapSurface(new Point(320, 0))).toBe(false); + }); + + test('Screen center', () => { + expect(globeTransform.isPointOnMapSurface(new Point(320, 240))).toBe(true); + }); + + test('Top', () => { + expect(globeTransform.isPointOnMapSurface(new Point(320, 104))).toBe(false); + expect(globeTransform.isPointOnMapSurface(new Point(320, 105))).toBe(true); + }); + + test('Bottom', () => { + expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 105))).toBe(true); + expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 104))).toBe(false); + }); + + test('Left', () => { + expect(globeTransform.isPointOnMapSurface(new Point(184, 240))).toBe(false); + expect(globeTransform.isPointOnMapSurface(new Point(185, 240))).toBe(true); + }); + + test('Right', () => { + expect(globeTransform.isPointOnMapSurface(new Point(640 - 185, 240))).toBe(true); + expect(globeTransform.isPointOnMapSurface(new Point(640 - 184, 240))).toBe(false); + }); + + test('Diagonal', () => { + expect(globeTransform.isPointOnMapSurface(new Point(223, 147))).toBe(true); + expect(globeTransform.isPointOnMapSurface(new Point(221, 144))).toBe(false); + }); + }); + + test('pointCoordinate', () => { + const precisionDigits = 10; + const globeTransform = createGlobeTransform(globeProjectionMock); + let coords: LngLat; + let coordsMercator: MercatorCoordinate; + let projected: Point; + let unprojectedCoordinates: MercatorCoordinate; + + coords = new LngLat(0, 0); + coordsMercator = MercatorCoordinate.fromLngLat(coords); + projected = globeTransform.locationToScreenPoint(coords); + unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected); + expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits); + expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits); + + coords = new LngLat(10, 20); + coordsMercator = MercatorCoordinate.fromLngLat(coords); + projected = globeTransform.locationToScreenPoint(coords); + unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected); + expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits); + expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits); + }); + + describe('globeViewAllowed', () => { + test('starts enabled', async () => { + const globeTransform = createGlobeTransform(globeProjectionMock); + + expect(globeTransform.getGlobeViewAllowed()).toBe(true); + expect(globeTransform['_globeRendering']).toBe(true); + }); + + test('animates to false', async () => { + const globeTransform = createGlobeTransform(globeProjectionMock); + globeTransform.newFrameUpdate(); + globeTransform.setGlobeViewAllowed(false); + + await sleep(20); + globeTransform.newFrameUpdate(); + expect(globeTransform.getGlobeViewAllowed()).toBe(false); + expect(globeTransform['_globeRendering']).toBe(true); + + await sleep(1000); + globeTransform.newFrameUpdate(); + expect(globeTransform.getGlobeViewAllowed()).toBe(false); + expect(globeTransform['_globeRendering']).toBe(false); + }); + + test('can skip animation if requested', async () => { + const globeTransform = createGlobeTransform(globeProjectionMock); + globeTransform.newFrameUpdate(); + globeTransform.setGlobeViewAllowed(false, false); + + await sleep(20); + globeTransform.newFrameUpdate(); + expect(globeTransform.getGlobeViewAllowed()).toBe(false); + expect(globeTransform['_globeRendering']).toBe(false); + }); + }); + + describe('getBounds', () => { + const precisionDigits = 10; + + const globeTransform = new GlobeTransform(globeProjectionMock); + globeTransform.resize(640, 480); + + test('basic', () => { + globeTransform.setCenter(new LngLat(0, 0)); + globeTransform.setZoom(1); + const bounds = globeTransform.getBounds(); + expect(bounds._ne.lat).toBeCloseTo(83.96012370156063, precisionDigits); + expect(bounds._ne.lng).toBeCloseTo(85.46274667048044, precisionDigits); + expect(bounds._sw.lat).toBeCloseTo(-83.96012370156063, precisionDigits); + expect(bounds._sw.lng).toBeCloseTo(-85.46274667048044, precisionDigits); + }); + + test('zoomed in', () => { + globeTransform.setCenter(new LngLat(0, 0)); + globeTransform.setZoom(4); + const bounds = globeTransform.getBounds(); + expect(bounds._ne.lat).toBeCloseTo(11.76627084591695, precisionDigits); + expect(bounds._ne.lng).toBeCloseTo(16.124697669965144, precisionDigits); + expect(bounds._sw.lat).toBeCloseTo(-11.76627084591695, precisionDigits); + expect(bounds._sw.lng).toBeCloseTo(-16.124697669965144, precisionDigits); + }); + + test('looking at south pole', () => { + globeTransform.setCenter(new LngLat(0, -84)); + globeTransform.setZoom(-2); + const bounds = globeTransform.getBounds(); + expect(bounds._ne.lat).toBeCloseTo(-1.2776252401855572, precisionDigits); + expect(bounds._ne.lng).toBeCloseTo(180, precisionDigits); + expect(bounds._sw.lat).toBeCloseTo(-90, precisionDigits); + expect(bounds._sw.lng).toBeCloseTo(-180, precisionDigits); + }); + + test('looking at south edge of mercator', () => { + globeTransform.setCenter(new LngLat(-163, -83)); + globeTransform.setZoom(3); + const bounds = globeTransform.getBounds(); + expect(bounds._ne.lat).toBeCloseTo(-79.75570418234764, precisionDigits); + expect(bounds._ne.lng).toBeCloseTo(-124.19771985801174, precisionDigits); + expect(bounds._sw.lat).toBeCloseTo(-85.59109073899032, precisionDigits); + expect(bounds._sw.lng).toBeCloseTo(-201.80228014198985, precisionDigits); + }); + }); + + describe('projectTileCoordinates', () => { + const precisionDigits = 10; + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(512, 512); + transform.setCenter(new LngLat(10.0, 50.0)); + transform.setZoom(-1); + + test('basic', () => { + + const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0); + expect(projection.point.x).toBeCloseTo(0.008635590705360347, precisionDigits); + expect(projection.point.y).toBeCloseTo(0.16970500709841846, precisionDigits); + expect(projection.signedDistanceFromCamera).toBeCloseTo(781.0549201758624, precisionDigits); + expect(projection.isOccluded).toBe(false); + }); + + test('rotated', () => { + transform.setBearing(12); + transform.setPitch(10); + + const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0); + expect(projection.point.x).toBeCloseTo(-0.026585319983152694, precisionDigits); + expect(projection.point.y).toBeCloseTo(0.15506884411121183, precisionDigits); + expect(projection.signedDistanceFromCamera).toBeCloseTo(788.4423931260653, precisionDigits); + expect(projection.isOccluded).toBe(false); + }); + + test('occluded by planet', () => { + transform.setBearing(-90); + transform.setPitch(60); + + const projection = transform.projectTileCoordinates(8192, 8192, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0); + expect(projection.point.x).toBeCloseTo(0.22428309892086878, precisionDigits); + expect(projection.point.y).toBeCloseTo(-0.4462620847133465, precisionDigits); + expect(projection.signedDistanceFromCamera).toBeCloseTo(822.280942015371, precisionDigits); + expect(projection.isOccluded).toBe(true); + }); + }); + + describe('isLocationOccluded', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(512, 512); + transform.setCenter(new LngLat(0.0, 0.0)); + transform.setZoom(-1); + + test('center', () => { + expect(transform.isLocationOccluded(new LngLat(0, 0))).toBe(false); + }); + + test('center from tile', () => { + expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 1, 1)))).toBe(false); + }); + + test('backside', () => { + expect(transform.isLocationOccluded(new LngLat(179.9, 0))).toBe(true); + }); + + test('backside from tile', () => { + expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 0, 1)))).toBe(true); + }); + + test('barely visible', () => { + expect(transform.isLocationOccluded(new LngLat(84.49, 0))).toBe(false); + }); + + test('barely hidden', () => { + expect(transform.isLocationOccluded(new LngLat(84.50, 0))).toBe(true); + }); + }); + + describe('coveringTiles', () => { + test('zoomed out', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(128, 128); + transform.setCenter(new LngLat(0.0, 0.0)); + transform.setZoom(-1); + + const tiles = transform.coveringTiles({ + tileSize: 512, + }); + + expect(tiles).toEqual([ + new OverscaledTileID(0, 0, 0, 0, 0) + ]); + }); + + test('zoomed in', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(128, 128); + transform.setCenter(new LngLat(0.0, 0.0)); + transform.setZoom(3); + + const tiles = transform.coveringTiles({ + tileSize: 512, + }); + + expect(tiles).toEqual([ + new OverscaledTileID(3, 0, 3, 3, 3), + new OverscaledTileID(3, 0, 3, 3, 4), + new OverscaledTileID(3, 0, 3, 4, 3), + new OverscaledTileID(3, 0, 3, 4, 4), + ]); + }); + + test('zoomed in 512x512', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(512, 512); + transform.setCenter(new LngLat(0.0, 0.0)); + transform.setZoom(3); + + const tiles = transform.coveringTiles({ + tileSize: 512, + }); + + expect(tiles).toEqual([ + new OverscaledTileID(3, 0, 3, 2, 2), + new OverscaledTileID(3, 0, 3, 2, 3), + new OverscaledTileID(3, 0, 3, 3, 3), + new OverscaledTileID(3, 0, 3, 2, 4), + new OverscaledTileID(3, 0, 3, 3, 4), + new OverscaledTileID(3, 0, 3, 4, 3), + new OverscaledTileID(3, 0, 3, 2, 5), + new OverscaledTileID(3, 0, 3, 5, 2), + new OverscaledTileID(3, 0, 3, 4, 4), + new OverscaledTileID(3, 0, 3, 5, 3), + new OverscaledTileID(3, 0, 3, 5, 4), + new OverscaledTileID(3, 0, 3, 5, 5), + ]); + }); + + test('pitched', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(128, 128); + transform.setCenter(new LngLat(0.0, 0.0)); + transform.setZoom(8); + transform.setMaxPitch(80); + transform.setPitch(80); + + const tiles = transform.coveringTiles({ + tileSize: 512, + }); + + expect(tiles).toEqual([ + new OverscaledTileID(8, 0, 8, 127, 126), + new OverscaledTileID(8, 0, 8, 127, 127), + new OverscaledTileID(8, 0, 8, 128, 126), + new OverscaledTileID(8, 0, 8, 127, 128), + new OverscaledTileID(8, 0, 8, 128, 127), + new OverscaledTileID(8, 0, 8, 128, 128), + ]); + }); + + test('pitched+rotated', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(128, 128); + transform.setCenter(new LngLat(0.0, 0.0)); + transform.setZoom(8); + transform.setMaxPitch(80); + transform.setPitch(80); + transform.setBearing(45); + + const tiles = transform.coveringTiles({ + tileSize: 512, + }); + + expect(tiles).toEqual([ + new OverscaledTileID(8, 0, 8, 128, 125), + new OverscaledTileID(8, 0, 8, 127, 127), + new OverscaledTileID(8, 0, 8, 128, 126), + new OverscaledTileID(8, 0, 8, 127, 128), + new OverscaledTileID(8, 0, 8, 128, 127), + new OverscaledTileID(8, 0, 8, 129, 126), + new OverscaledTileID(8, 0, 8, 128, 128), + new OverscaledTileID(8, 0, 8, 129, 127), + new OverscaledTileID(8, 0, 8, 130, 127), + ]); + }); + + test('antimeridian1', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(128, 128); + transform.setCenter(new LngLat(179.99, 0.0)); + transform.setZoom(5); + + const tiles = transform.coveringTiles({ + tileSize: 512, + }); + + expect(tiles).toEqual([ + new OverscaledTileID(5, 1, 5, 0, 15), + new OverscaledTileID(5, 1, 5, 0, 16), + new OverscaledTileID(5, 0, 5, 31, 15), + new OverscaledTileID(5, 0, 5, 31, 16), + ]); + }); + + test('antimeridian2', () => { + const transform = new GlobeTransform(globeProjectionMock); + transform.resize(128, 128); + transform.setCenter(new LngLat(-179.99, 0.0)); + transform.setZoom(5); + + const tiles = transform.coveringTiles({ + tileSize: 512, + }); + + expect(tiles).toEqual([ + new OverscaledTileID(5, 0, 5, 0, 15), + new OverscaledTileID(5, 0, 5, 0, 16), + new OverscaledTileID(5, -1, 5, 31, 15), + new OverscaledTileID(5, -1, 5, 31, 16), + ]); + }); + }); +}); diff --git a/src/geo/projection/globe_transform.ts b/src/geo/projection/globe_transform.ts new file mode 100644 index 0000000000..eec39ac70c --- /dev/null +++ b/src/geo/projection/globe_transform.ts @@ -0,0 +1,1200 @@ +import {mat2, mat4, vec3, vec4} from 'gl-matrix'; +import {MAX_VALID_LATITUDE, TransformHelper} from '../transform_helper'; +import {MercatorTransform} from './mercator_transform'; +import {LngLat, LngLatLike, earthRadius} from '../lng_lat'; +import {angleToRotateBetweenVectors2D, clamp, createIdentityMat4f64, createMat4f64, createVec3f64, createVec4f64, differenceOfAnglesDegrees, distanceOfAnglesRadians, easeCubicInOut, lerp, pointPlaneSignedDistance, warnOnce} from '../../util/util'; +import {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../../source/tile_id'; +import Point from '@mapbox/point-geometry'; +import {browser} from '../../util/browser'; +import {Terrain} from '../../render/terrain'; +import {GlobeProjection, globeConstants} from './globe'; +import {MercatorCoordinate} from '../mercator_coordinate'; +import {PointProjection} from '../../symbol/projection'; +import {LngLatBounds} from '../lng_lat_bounds'; +import {CoveringTilesOptions, CoveringZoomOptions, IReadonlyTransform, ITransform, TransformUpdateResult} from '../transform_interface'; +import {PaddingOptions} from '../edge_insets'; +import {tileCoordinatesToLocation, tileCoordinatesToMercatorCoordinates} from './mercator_utils'; +import {angularCoordinatesToSurfaceVector, getGlobeRadiusPixels, getZoomAdjustment, mercatorCoordinatesToAngularCoordinatesRadians, projectTileCoordinatesToSphere, sphereSurfacePointToCoordinates} from './globe_utils'; +import {EXTENT} from '../../data/extent'; +import type {ProjectionData} from './projection_data'; +import {globeCoveringTiles} from './globe_covering_tiles'; +import {Frustum} from '../../util/primitives'; + +/** + * Describes the intersection of ray and sphere. + * When null, no intersection occurred. + * When both "t" values are the same, the ray just touched the sphere's surface. + * When both value are different, a full intersection occurred. + */ +type RaySphereIntersection = { + /** + * The ray parameter for intersection that is "less" along the ray direction. + * Note that this value can be negative, meaning that this intersection occurred before the ray's origin. + * The intersection point can be computed as `origin + direction * tMin`. + */ + tMin: number; + /** + * The ray parameter for intersection that is "more" along the ray direction. + * Note that this value can be negative, meaning that this intersection occurred before the ray's origin. + * The intersection point can be computed as `origin + direction * tMax`. + */ + tMax: number; +} | null; + +export class GlobeTransform implements ITransform { + private _helper: TransformHelper; + + // + // Implementation of transform getters and setters + // + + get pixelsToClipSpaceMatrix(): mat4 { + return this._helper.pixelsToClipSpaceMatrix; + } + get clipSpaceToPixelsMatrix(): mat4 { + return this._helper.clipSpaceToPixelsMatrix; + } + get pixelsToGLUnits(): [number, number] { + return this._helper.pixelsToGLUnits; + } + get centerOffset(): Point { + return this._helper.centerOffset; + } + get size(): Point { + return this._helper.size; + } + get rotationMatrix(): mat2 { + return this._helper.rotationMatrix; + } + get centerPoint(): Point { + return this._helper.centerPoint; + } + get pixelsPerMeter(): number { + return this._helper.pixelsPerMeter; + } + setMinZoom(zoom: number): void { + this._helper.setMinZoom(zoom); + } + setMaxZoom(zoom: number): void { + this._helper.setMaxZoom(zoom); + } + setMinPitch(pitch: number): void { + this._helper.setMinPitch(pitch); + } + setMaxPitch(pitch: number): void { + this._helper.setMaxPitch(pitch); + } + setRenderWorldCopies(renderWorldCopies: boolean): void { + this._helper.setRenderWorldCopies(renderWorldCopies); + } + setBearing(bearing: number): void { + this._helper.setBearing(bearing); + } + setPitch(pitch: number): void { + this._helper.setPitch(pitch); + } + setFov(fov: number): void { + this._helper.setFov(fov); + } + setZoom(zoom: number): void { + this._helper.setZoom(zoom); + } + setCenter(center: LngLat): void { + this._helper.setCenter(center); + } + setElevation(elevation: number): void { + this._helper.setElevation(elevation); + } + setMinElevationForCurrentTile(elevation: number): void { + this._helper.setMinElevationForCurrentTile(elevation); + } + setPadding(padding: PaddingOptions): void { + this._helper.setPadding(padding); + } + interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void { + return this._helper.interpolatePadding(start, target, t); + } + isPaddingEqual(padding: PaddingOptions): boolean { + return this._helper.isPaddingEqual(padding); + } + coveringZoomLevel(options: CoveringZoomOptions): number { + return this._helper.coveringZoomLevel(options); + } + resize(width: number, height: number): void { + this._helper.resize(width, height); + } + getMaxBounds(): LngLatBounds { + return this._helper.getMaxBounds(); + } + setMaxBounds(bounds?: LngLatBounds): void { + this._helper.setMaxBounds(bounds); + } + getCameraQueryGeometry(queryGeometry: Point[]): Point[] { + return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry); + } + + get tileSize(): number { + return this._helper.tileSize; + } + get tileZoom(): number { + return this._helper.tileZoom; + } + get scale(): number { + return this._helper.scale; + } + get worldSize(): number { + return this._helper.worldSize; + } + get width(): number { + return this._helper.width; + } + get height(): number { + return this._helper.height; + } + get angle(): number { + return this._helper.angle; + } + get lngRange(): [number, number] { + return this._helper.lngRange; + } + get latRange(): [number, number] { + return this._helper.latRange; + } + get minZoom(): number { + return this._helper.minZoom; + } + get maxZoom(): number { + return this._helper.maxZoom; + } + get zoom(): number { + return this._helper.zoom; + } + get center(): LngLat { + return this._helper.center; + } + get minPitch(): number { + return this._helper.minPitch; + } + get maxPitch(): number { + return this._helper.maxPitch; + } + get pitch(): number { + return this._helper.pitch; + } + get bearing(): number { + return this._helper.bearing; + } + get fov(): number { + return this._helper.fov; + } + get elevation(): number { + return this._helper.elevation; + } + get minElevationForCurrentTile(): number { + return this._helper.minElevationForCurrentTile; + } + get padding(): PaddingOptions { + return this._helper.padding; + } + get unmodified(): boolean { + return this._helper.unmodified; + } + get renderWorldCopies(): boolean { + return this._helper.renderWorldCopies; + } + + // + // Implementation of globe transform + // + + private _cachedClippingPlane: vec4 = createVec4f64(); + private _cachedFrustum: Frustum; + + // Transition handling + private _lastGlobeStateEnabled: boolean = true; + + private _lastLargeZoomStateChange: number = -1000.0; + private _lastLargeZoomState: boolean = false; + + /** + * Stores when {@link newFrameUpdate} was last called. + * Serves as a unified clock for globe (instead of each function using a slightly different value from `browser.now()`). + */ + private _lastUpdateTime = browser.now(); + /** + * Stores when switch from globe to mercator or back last occurred, for animation purposes. + * This switch can be caused either by the map passing the threshold zoom level, + * or by {@link setGlobeViewAllowed} being called. + */ + private _lastGlobeChangeTime: number = browser.now() - 10_000; // Ten seconds before transform creation + + private _skipNextAnimation: boolean = true; + + private _projectionMatrix: mat4 = createIdentityMat4f64(); + private _globeViewProjMatrix: mat4 = createIdentityMat4f64(); + private _globeViewProjMatrix32f: mat4; + private _globeViewProjMatrixNoCorrection: mat4 = createIdentityMat4f64(); + private _globeViewProjMatrixNoCorrectionInverted: mat4 = createIdentityMat4f64(); + private _globeProjMatrixInverted: mat4 = createIdentityMat4f64(); + + private _cameraPosition: vec3 = createVec3f64(); + + /** + * Whether globe projection is allowed to be used. + * Set with {@link setGlobeViewAllowed}. + * Can be used to dynamically disable globe projection without changing the map's projection, + * which would cause a map reload. + */ + private _globeProjectionAllowed = true; + + /** + * Note: projection instance should only be accessed in the {@link newFrameUpdate} function. + * to ensure the transform's state isn't unintentionally changed. + */ + private _projectionInstance: GlobeProjection; + private _globeLatitudeErrorCorrectionRadians: number = 0; + + private get _globeRendering(): boolean { + return this._globeness > 0; + } + + /** + * Globe projection can smoothly interpolate between globe view and mercator. This variable controls this interpolation. + * Value 0 is mercator, value 1 is globe, anything between is an interpolation between the two projections. + */ + private _globeness: number = 1.0; + private _mercatorTransform: MercatorTransform; + + private _nearZ; + private _farZ; + + public constructor(globeProjection: GlobeProjection, globeProjectionEnabled: boolean = true) { + this._helper = new TransformHelper({ + calcMatrices: () => { this._calcMatrices(); }, + getConstrained: (center, zoom) => { return this.getConstrained(center, zoom); } + }); + this._globeProjectionAllowed = globeProjectionEnabled; + this._globeness = globeProjectionEnabled ? 1 : 0; // When transform is cloned for use in symbols, `_updateAnimation` function which usually sets this value never gets called. + this._projectionInstance = globeProjection; + this._mercatorTransform = new MercatorTransform(); + } + + clone(): ITransform { + const clone = new GlobeTransform(null, this._globeProjectionAllowed); + clone._applyGlobeTransform(this); + clone.apply(this); + return clone; + } + + public apply(that: IReadonlyTransform): void { + this._helper.apply(that); + this._mercatorTransform.apply(this); + } + + private _applyGlobeTransform(that: GlobeTransform): void { + this._globeness = that._globeness; + this._globeLatitudeErrorCorrectionRadians = that._globeLatitudeErrorCorrectionRadians; + } + + public get projectionMatrix(): mat4 { return this._globeRendering ? this._projectionMatrix : this._mercatorTransform.projectionMatrix; } + + public get modelViewProjectionMatrix(): mat4 { return this._globeRendering ? this._globeViewProjMatrixNoCorrection : this._mercatorTransform.modelViewProjectionMatrix; } + + public get inverseProjectionMatrix(): mat4 { return this._globeRendering ? this._globeProjMatrixInverted : this._mercatorTransform.inverseProjectionMatrix; } + + public get useGlobeControls(): boolean { return this._globeRendering; } + + public get cameraPosition(): vec3 { + // Return a copy - don't let outside code mutate our precomputed camera position. + const copy = createVec3f64(); // Ensure the resulting vector is float64s + copy[0] = this._cameraPosition[0]; + copy[1] = this._cameraPosition[1]; + copy[2] = this._cameraPosition[2]; + return copy; + } + + get cameraToCenterDistance(): number { + // Globe uses the same cameraToCenterDistance as mercator. + return this._mercatorTransform.cameraToCenterDistance; + } + + public get nearZ(): number { return this._nearZ; } + public get farZ(): number { return this._farZ; } + + /** + * Returns whether globe view is allowed. + * When allowed, globe fill function as normal, displaying a 3D planet, + * but transitioning to mercator at high zoom levels. + * Otherwise, mercator will be used at all zoom levels instead. + * Set with {@link setGlobeViewAllowed}. + */ + public getGlobeViewAllowed(): boolean { + return this._globeProjectionAllowed; + } + + /** + * Sets whether globe view is allowed. When allowed, globe fill function as normal, displaying a 3D planet, + * but transitioning to mercator at high zoom levels. + * Otherwise, mercator will be used at all zoom levels instead. + * When globe is caused to transition to mercator by this function, the transition will be animated. + * @param allow - Sets whether glove view is allowed. + * @param animateTransition - Controls whether the transition between globe view and mercator (if triggered by this call) should be animated. True by default. + */ + public setGlobeViewAllowed(allow: boolean, animateTransition: boolean = true) { + if (allow === this._globeProjectionAllowed) { + return; + } + + if (!animateTransition) { + this._skipNextAnimation = true; + } + this._globeProjectionAllowed = allow; + this._lastGlobeChangeTime = this._lastUpdateTime; + } + + /** + * Should be called at the beginning of every frame to synchronize the transform with the underlying projection. + */ + newFrameUpdate(): TransformUpdateResult { + this._updateErrorCorrectionValue(); + + this._lastUpdateTime = browser.now(); + const oldGlobeRendering = this._globeRendering; + this._globeness = this._computeGlobenessAnimation(); + + this._calcMatrices(); + + if (oldGlobeRendering === this._globeRendering) { + return { + forcePlacementUpdate: false, + }; + } else { + return { + forcePlacementUpdate: true, + fireProjectionEvent: { + type: 'projectiontransition', + newProjection: this._globeRendering ? 'globe' : 'globe-mercator', + }, + forceSourceUpdate: true, + }; + } + } + + /** + * This function should never be called on a cloned transform, thus ensuring that + * the state of a cloned transform is never changed after creation. + */ + private _updateErrorCorrectionValue(): void { + if (!this._projectionInstance) { + return; + } + this._projectionInstance.useGlobeRendering = this._globeRendering; + this._projectionInstance.errorQueryLatitudeDegrees = this.center.lat; + this._globeLatitudeErrorCorrectionRadians = this._projectionInstance.latitudeErrorCorrectionRadians; + } + + /** + * Compute new globeness, if needed. + */ + private _computeGlobenessAnimation(): number { + // Update globe transition animation + const globeState = this._globeProjectionAllowed; + const currentTime = this._lastUpdateTime; + if (globeState !== this._lastGlobeStateEnabled) { + this._lastGlobeChangeTime = currentTime; + this._lastGlobeStateEnabled = globeState; + } + + const oldGlobeness = this._globeness; + + // Transition parameter, where 0 is the start and 1 is end. + const globeTransition = Math.min(Math.max((currentTime - this._lastGlobeChangeTime) / 1000.0 / globeConstants.globeTransitionTimeSeconds, 0.0), 1.0); + let newGlobeness = globeState ? globeTransition : (1.0 - globeTransition); + + if (this._skipNextAnimation) { + newGlobeness = globeState ? 1.0 : 0.0; + this._lastGlobeChangeTime = currentTime - globeConstants.globeTransitionTimeSeconds * 1000.0 * 2.0; + this._skipNextAnimation = false; + } + + // Update globe zoom transition + const currentZoomState = this.zoom >= globeConstants.maxGlobeZoom; + if (currentZoomState !== this._lastLargeZoomState) { + this._lastLargeZoomState = currentZoomState; + this._lastLargeZoomStateChange = currentTime; + } + const zoomTransition = Math.min(Math.max((currentTime - this._lastLargeZoomStateChange) / 1000.0 / globeConstants.zoomTransitionTimeSeconds, 0.0), 1.0); + const zoomGlobenessBound = currentZoomState ? (1.0 - zoomTransition) : zoomTransition; + newGlobeness = Math.min(newGlobeness, zoomGlobenessBound); + newGlobeness = easeCubicInOut(newGlobeness); // Smooth animation + + if (oldGlobeness !== newGlobeness) { + this.setCenter(new LngLat( + this._mercatorTransform.center.lng + differenceOfAnglesDegrees(this._mercatorTransform.center.lng, this.center.lng) * newGlobeness, + lerp(this._mercatorTransform.center.lat, this.center.lat, newGlobeness) + )); + this.setZoom(lerp(this._mercatorTransform.zoom, this.zoom, newGlobeness)); + } + + return newGlobeness; + } + + isRenderingDirty(): boolean { + // Globe transition + return (this._lastUpdateTime - this._lastGlobeChangeTime) / 1000.0 < (Math.max(globeConstants.globeTransitionTimeSeconds, globeConstants.zoomTransitionTimeSeconds)); + } + + getProjectionData(overscaledTileID: OverscaledTileID, aligned?: boolean, ignoreTerrainMatrix?: boolean): ProjectionData { + const data = this._mercatorTransform.getProjectionData(overscaledTileID, aligned, ignoreTerrainMatrix); + + // Set 'projectionMatrix' to actual globe transform + if (this._globeRendering) { + data.mainMatrix = this._globeViewProjMatrix32f; + } + + data.clippingPlane = this._cachedClippingPlane as [number, number, number, number]; + data.projectionTransition = this._globeness; + + return data; + } + + private _computeClippingPlane(globeRadiusPixels: number): vec4 { + // We want to compute a plane equation that, when applied to the unit sphere generated + // in the vertex shader, places all visible parts of the sphere into the positive half-space + // and all the non-visible parts in the negative half-space. + // We can then use that to accurately clip all non-visible geometry. + + // cam....------------A + // .... | + // .... | + // ....B + // ggggggggg + // gggggg | .gggggg + // ggg | ...ggg ^ + // gg | | + // g | y + // g | | + // g C #---x---> + // + // Notes: + // - note the coordinate axes + // - "g" marks the globe edge + // - the dotted line is the camera center "ray" - we are looking in this direction + // - "cam" is camera origin + // - "C" is globe center + // - "B" is the point on "top" of the globe - camera is looking at B - "B" is the intersection between the camera center ray and the globe + // - this._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 = this.pitch * Math.PI / 180.0; + // scale things so that the globe radius is 1 + const distanceCameraToB = this.cameraToCenterDistance / globeRadiusPixels; + const radius = 1; + + // Distance from camera to "A" - the point at the same elevation as camera, right above center point on globe + const distanceCameraToA = Math.sin(pitch) * distanceCameraToB; + // Distance from "A" to "C" + const distanceAtoC = (Math.cos(pitch) * distanceCameraToB + radius); + // Distance from camera to "C" - the globe center + const distanceCameraToC = Math.sqrt(distanceCameraToA * distanceCameraToA + distanceAtoC * distanceAtoC); + // cam - C - T angle cosine (at C) + const camCTcosine = radius / distanceCameraToC; + // Distance from globe center to the plane defined by all possible "T" points + const tangentPlaneDistanceToC = camCTcosine * radius; + + let vectorCtoCamX = -distanceCameraToA; + let vectorCtoCamY = distanceAtoC; + // Normalize the vector + const vectorCtoCamLength = Math.sqrt(vectorCtoCamX * vectorCtoCamX + vectorCtoCamY * vectorCtoCamY); + vectorCtoCamX /= vectorCtoCamLength; + vectorCtoCamY /= vectorCtoCamLength; + + // Note the swizzled components + const planeVector: vec3 = [0, vectorCtoCamX, vectorCtoCamY]; + // Apply transforms - lat, lng and angle (NOT pitch - already accounted for, as it affects the tangent plane) + vec3.rotateZ(planeVector, planeVector, [0, 0, 0], this.angle); + vec3.rotateX(planeVector, planeVector, [0, 0, 0], -1 * this.center.lat * Math.PI / 180.0); + vec3.rotateY(planeVector, planeVector, [0, 0, 0], this.center.lng * Math.PI / 180.0); + // Scale the plane vector up + // we don't want the actually visible parts of the sphere to end up beyond distance 1 from the plane - otherwise they would be clipped by the near plane. + const scale = 0.25; + vec3.scale(planeVector, planeVector, scale); + return [...planeVector, -tangentPlaneDistanceToC * scale]; + } + + public isLocationOccluded(location: LngLat): boolean { + return !this.isSurfacePointVisible(angularCoordinatesToSurfaceVector(location)); + } + + public tileCoordinatesOccluded(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): boolean { + if (!this._globeRendering) { + return this._mercatorTransform.tileCoordinatesOccluded(inTileX, inTileY, canonicalTileID); + } + const location = tileCoordinatesToLocation(inTileX, inTileY, canonicalTileID); + return !this.isSurfacePointVisible(angularCoordinatesToSurfaceVector(location)); + } + + public transformLightDirection(dir: vec3): vec3 { + const sphereX = this._helper._center.lng * Math.PI / 180.0; + const sphereY = this._helper._center.lat * Math.PI / 180.0; + + const len = Math.cos(sphereY); + const spherePos: vec3 = [ + Math.sin(sphereX) * len, + Math.sin(sphereY), + Math.cos(sphereX) * len + ]; + + const axisRight: vec3 = [spherePos[2], 0.0, -spherePos[0]]; // Equivalent to cross(vec3(0.0, 1.0, 0.0), vec) + const axisDown: vec3 = [0, 0, 0]; + vec3.cross(axisDown, axisRight, spherePos); + vec3.normalize(axisRight, axisRight); + vec3.normalize(axisDown, axisDown); + + const transformed: vec3 = [ + axisRight[0] * dir[0] + axisDown[0] * dir[1] + spherePos[0] * dir[2], + axisRight[1] * dir[0] + axisDown[1] * dir[1] + spherePos[1] * dir[2], + axisRight[2] * dir[0] + axisDown[2] * dir[1] + spherePos[2] * dir[2] + ]; + + const normalized: vec3 = [0, 0, 0]; + vec3.normalize(normalized, transformed); + return normalized; + } + + private getAnimatedLatitude() { + return lerp(this._mercatorTransform.center.lat, this._helper._center.lat, this._globeness); + } + + public getPixelScale(): number { + return 1.0 / Math.cos(this.getAnimatedLatitude() * Math.PI / 180); + } + + public getCircleRadiusCorrection(): number { + return Math.cos(this.getAnimatedLatitude() * Math.PI / 180); + } + + public getPitchedTextCorrection(textAnchorX: number, textAnchorY: number, tileID: UnwrappedTileID): number { + if (!this._globeRendering) { + return 1.0; + } + const mercator = tileCoordinatesToMercatorCoordinates(textAnchorX, textAnchorY, tileID.canonical); + const angular = mercatorCoordinatesToAngularCoordinatesRadians(mercator.x, mercator.y); + return this.getCircleRadiusCorrection() / Math.cos(angular[1]); + } + + public projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection { + if (!this._globeRendering) { + return this._mercatorTransform.projectTileCoordinates(x, y, unwrappedTileID, getElevation); + } + + const canonical = unwrappedTileID.canonical; + const spherePos = projectTileCoordinatesToSphere(x, y, canonical.x, canonical.y, canonical.z); + const elevation = getElevation ? getElevation(x, y) : 0.0; + const vectorMultiplier = 1.0 + elevation / earthRadius; + const pos: vec4 = [spherePos[0] * vectorMultiplier, spherePos[1] * vectorMultiplier, spherePos[2] * vectorMultiplier, 1]; + vec4.transformMat4(pos, pos, this._globeViewProjMatrixNoCorrection); + + // Also check whether the point projects to the backfacing side of the sphere. + const plane = this._cachedClippingPlane; + // dot(position on sphere, occlusion plane equation) + const dotResult = plane[0] * spherePos[0] + plane[1] * spherePos[1] + plane[2] * spherePos[2] + plane[3]; + const isOccluded = dotResult < 0.0; + + return { + point: new Point(pos[0] / pos[3], pos[1] / pos[3]), + signedDistanceFromCamera: pos[3], + isOccluded + }; + } + + private _calcMatrices(): void { + if (!this._helper._width || !this._helper._height) { + return; + } + + if (this._mercatorTransform) { + this._mercatorTransform.apply(this, true); + } + + const globeRadiusPixels = getGlobeRadiusPixels(this.worldSize, this.center.lat); + + // Construct a completely separate matrix for globe view + const globeMatrix = createMat4f64(); + const globeMatrixUncorrected = createMat4f64(); + this._nearZ = 0.5; + this._farZ = this.cameraToCenterDistance + globeRadiusPixels * 2.0; // just set the far plane far enough - we will calculate our own z in the vertex shader anyway + mat4.perspective(globeMatrix, this.fov * Math.PI / 180, this.width / this.height, this._nearZ, this._farZ); + + // Apply center of perspective offset + const offset = this.centerOffset; + globeMatrix[8] = -offset.x * 2 / this._helper._width; + globeMatrix[9] = offset.y * 2 / this._helper._height; + this._projectionMatrix = mat4.clone(globeMatrix); + + this._globeProjMatrixInverted = createMat4f64(); + mat4.invert(this._globeProjMatrixInverted, globeMatrix); + mat4.translate(globeMatrix, globeMatrix, [0, 0, -this.cameraToCenterDistance]); + mat4.rotateX(globeMatrix, globeMatrix, -this.pitch * Math.PI / 180); + mat4.rotateZ(globeMatrix, globeMatrix, -this.angle); + mat4.translate(globeMatrix, globeMatrix, [0.0, 0, -globeRadiusPixels]); + // Rotate the sphere to center it on viewed coordinates + + const scaleVec = createVec3f64(); + scaleVec[0] = globeRadiusPixels; + scaleVec[1] = globeRadiusPixels; + scaleVec[2] = globeRadiusPixels; + + // Keep a atan-correction-free matrix for transformations done on the CPU with accurate math + mat4.rotateX(globeMatrixUncorrected, globeMatrix, this.center.lat * Math.PI / 180.0); + mat4.rotateY(globeMatrixUncorrected, globeMatrixUncorrected, -this.center.lng * Math.PI / 180.0); + mat4.scale(globeMatrixUncorrected, globeMatrixUncorrected, scaleVec); // Scale the unit sphere to a sphere with diameter of 1 + this._globeViewProjMatrixNoCorrection = globeMatrixUncorrected; + + mat4.rotateX(globeMatrix, globeMatrix, this.center.lat * Math.PI / 180.0 - this._globeLatitudeErrorCorrectionRadians); + mat4.rotateY(globeMatrix, globeMatrix, -this.center.lng * Math.PI / 180.0); + mat4.scale(globeMatrix, globeMatrix, scaleVec); // Scale the unit sphere to a sphere with diameter of 1 + this._globeViewProjMatrix = globeMatrix; + this._globeViewProjMatrix32f = new Float32Array(globeMatrix); + + this._globeViewProjMatrixNoCorrectionInverted = createMat4f64(); + mat4.invert(this._globeViewProjMatrixNoCorrectionInverted, globeMatrixUncorrected); + + const zero = createVec3f64(); + this._cameraPosition = createVec3f64(); + this._cameraPosition[2] = this.cameraToCenterDistance / globeRadiusPixels; + vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, this.pitch * Math.PI / 180); + vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, this.angle); + vec3.add(this._cameraPosition, this._cameraPosition, [0, 0, 1]); + vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, -this.center.lat * Math.PI / 180.0); + vec3.rotateY(this._cameraPosition, this._cameraPosition, zero, this.center.lng * Math.PI / 180.0); + + this._cachedClippingPlane = this._computeClippingPlane(globeRadiusPixels); + + const matrix = mat4.clone(this._globeViewProjMatrixNoCorrectionInverted); + mat4.scale(matrix, matrix, [1, 1, -1]); + this._cachedFrustum = Frustum.fromInvProjectionMatrix(matrix); + } + + calculateFogMatrix(_unwrappedTileID: UnwrappedTileID): mat4 { + warnOnce('calculateFogMatrix is not supported on globe projection.'); + const m = createMat4f64(); + mat4.identity(m); + return m; + } + + getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): UnwrappedTileID[] { + // Globe has no wrap. + return [new UnwrappedTileID(0, tileID)]; + } + + coveringTiles(options: CoveringTilesOptions): OverscaledTileID[] { + if (!this._globeRendering) { + return this._mercatorTransform.coveringTiles(options); + } + + const coveringZ = this.coveringZoomLevel(options); + const cameraCoord = this.screenPointToMercatorCoordinate(this.getCameraPoint()); + const centerCoord = MercatorCoordinate.fromLngLat(this.center); + + return globeCoveringTiles(this._cachedFrustum, this._cachedClippingPlane, cameraCoord, centerCoord, coveringZ, options); + } + + recalculateZoom(terrain: Terrain): void { + this._mercatorTransform.recalculateZoom(terrain); + this.apply(this._mercatorTransform); + } + + maxPitchScaleFactor(): number { + // Using mercator version of this should be good enough approximation for globe. + return this._mercatorTransform.maxPitchScaleFactor(); + } + + getCameraPoint(): Point { + return this._mercatorTransform.getCameraPoint(); + } + + getCameraAltitude(): number { + return this._mercatorTransform.getCameraAltitude(); + } + + lngLatToCameraDepth(lngLat: LngLat, elevation: number): number { + if (!this._globeRendering) { + return this._mercatorTransform.lngLatToCameraDepth(lngLat, elevation); + } + if (!this._globeViewProjMatrixNoCorrection) { + return 1.0; // _calcMatrices hasn't run yet + } + const vec = angularCoordinatesToSurfaceVector(lngLat); + vec3.scale(vec, vec, (1.0 + elevation / earthRadius)); + const result = createVec4f64(); + vec4.transformMat4(result, [vec[0], vec[1], vec[2], 1], this._globeViewProjMatrixNoCorrection); + return result[2] / result[3]; + } + + precacheTiles(coords: OverscaledTileID[]): void { + this._mercatorTransform.precacheTiles(coords); + } + + getBounds(): LngLatBounds { + if (!this._globeRendering) { + return this._mercatorTransform.getBounds(); + } + + const xMid = this.width * 0.5; + const yMid = this.height * 0.5; + + // LngLat extremes will probably tend to be in screen corners or in middle of screen edges. + // These test points should result in a pretty good approximation. + const testPoints = [ + new Point(0, 0), + new Point(xMid, 0), + new Point(this.width, 0), + new Point(this.width, yMid), + new Point(this.width, this.height), + new Point(xMid, this.height), + new Point(0, this.height), + new Point(0, yMid), + ]; + + const projectedPoints = []; + for (const p of testPoints) { + projectedPoints.push(this.unprojectScreenPoint(p)); + } + + // We can't construct a simple min/max aabb, since points might lie on either side of the antimeridian. + // We will instead compute the furthest points relative to map center. + // We also take advantage of the fact that `unprojectScreenPoint` will snap pixels + // outside the planet to the closest point on the planet's horizon. + let mostEast = 0, mostWest = 0, mostNorth = 0, mostSouth = 0; // We will store these values signed. + const center = this.center; + for (const p of projectedPoints) { + const dLng = differenceOfAnglesDegrees(center.lng, p.lng); + const dLat = differenceOfAnglesDegrees(center.lat, p.lat); + if (dLng < mostWest) { + mostWest = dLng; + } + if (dLng > mostEast) { + mostEast = dLng; + } + if (dLat < mostSouth) { + mostSouth = dLat; + } + if (dLat > mostNorth) { + mostNorth = dLat; + } + } + + const boundsArray: [number, number, number, number] = [ + center.lng + mostWest, // west + center.lat + mostSouth, // south + center.lng + mostEast, // east + center.lat + mostNorth // north + ]; + + // Sometimes the poles might end up not being on the horizon, + // thus not being detected as the northernmost/southernmost points. + // We fix that here. + if (this.isSurfacePointOnScreen([0, 1, 0])) { + // North pole is visible + // This also means that the entire longitude range must be visible + boundsArray[3] = 90; + boundsArray[0] = -180; + boundsArray[2] = 180; + } + if (this.isSurfacePointOnScreen([0, -1, 0])) { + // South pole is visible + boundsArray[1] = -90; + boundsArray[0] = -180; + boundsArray[2] = 180; + } + + return new LngLatBounds(boundsArray); + } + + getConstrained(lngLat: LngLat, zoom: number): { center: LngLat; zoom: number } { + // Globe: TODO: respect _lngRange, _latRange + // It is possible to implement exact constrain for globe, but I don't think it is worth the effort. + const constrainedLat = clamp(lngLat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE); + const constrainedZoom = clamp(+zoom, this.minZoom + getZoomAdjustment(0, constrainedLat), this.maxZoom); + return { + center: new LngLat( + lngLat.lng, + constrainedLat + ), + zoom: constrainedZoom + }; + } + + /** + * Note: automatically adjusts zoom to keep planet size consistent + * (same size before and after a {@link setLocationAtPoint} call). + */ + setLocationAtPoint(lnglat: LngLat, point: Point): void { + if (!this._globeRendering) { + this._mercatorTransform.setLocationAtPoint(lnglat, point); + this.apply(this._mercatorTransform); + return; + } + // This returns some fake coordinates for pixels that do not lie on the planet. + // Whatever uses this `setLocationAtPoint` function will need to account for that. + const pointLngLat = this.unprojectScreenPoint(point); + const vecToPixelCurrent = angularCoordinatesToSurfaceVector(pointLngLat); + const vecToTarget = angularCoordinatesToSurfaceVector(lnglat); + + const zero = createVec3f64(); + vec3.zero(zero); + + const rotatedPixelVector = createVec3f64(); + vec3.rotateY(rotatedPixelVector, vecToPixelCurrent, zero, -this.center.lng * Math.PI / 180.0); + vec3.rotateX(rotatedPixelVector, rotatedPixelVector, zero, this.center.lat * Math.PI / 180.0); + + // We are looking for the lng,lat that will rotate `vecToTarget` + // so that it is equal to `rotatedPixelVector`. + + // The second rotation around X axis cannot change the X component, + // so we first must find the longitude such that rotating `vecToTarget` with it + // will place it so its X component is equal to X component of `rotatedPixelVector`. + // There will exist zero, one or two longitudes that satisfy this. + + // x | + // / | + // / | the line is the target X - rotatedPixelVector.x + // / | the x is vecToTarget projected to x,z plane + // . | the dot is origin + // + // We need to rotate vecToTarget so that it intersects the line. + // If vecToTarget is shorter than the distance to the line from origin, it is impossible. + + // Otherwise, we compute the intersection of the line with a ring with radius equal to + // length of vecToTarget projected to XZ plane. + + const vecToTargetXZLengthSquared = vecToTarget[0] * vecToTarget[0] + vecToTarget[2] * vecToTarget[2]; + const targetXSquared = rotatedPixelVector[0] * rotatedPixelVector[0]; + if (vecToTargetXZLengthSquared < targetXSquared) { + // Zero solutions - setLocationAtPoint is impossible. + return; + } + + // The intersection's Z coordinates + const intersectionA = Math.sqrt(vecToTargetXZLengthSquared - targetXSquared); + const intersectionB = -intersectionA; // the second solution + + const lngA = angleToRotateBetweenVectors2D(vecToTarget[0], vecToTarget[2], rotatedPixelVector[0], intersectionA); + const lngB = angleToRotateBetweenVectors2D(vecToTarget[0], vecToTarget[2], rotatedPixelVector[0], intersectionB); + + const vecToTargetLngA = createVec3f64(); + vec3.rotateY(vecToTargetLngA, vecToTarget, zero, -lngA); + const latA = angleToRotateBetweenVectors2D(vecToTargetLngA[1], vecToTargetLngA[2], rotatedPixelVector[1], rotatedPixelVector[2]); + const vecToTargetLngB = createVec3f64(); + vec3.rotateY(vecToTargetLngB, vecToTarget, zero, -lngB); + const latB = angleToRotateBetweenVectors2D(vecToTargetLngB[1], vecToTargetLngB[2], rotatedPixelVector[1], rotatedPixelVector[2]); + // Is at least one of the needed latitudes valid? + + const limit = Math.PI * 0.5; + + const isValidA = latA >= -limit && latA <= limit; + const isValidB = latB >= -limit && latB <= limit; + + let validLng: number; + let validLat: number; + if (isValidA && isValidB) { + // Pick the solution that is closer to current map center. + const centerLngRadians = this.center.lng * Math.PI / 180.0; + const centerLatRadians = this.center.lat * Math.PI / 180.0; + const lngDistA = distanceOfAnglesRadians(lngA, centerLngRadians); + const latDistA = distanceOfAnglesRadians(latA, centerLatRadians); + const lngDistB = distanceOfAnglesRadians(lngB, centerLngRadians); + const latDistB = distanceOfAnglesRadians(latB, centerLatRadians); + + if ((lngDistA + latDistA) < (lngDistB + latDistB)) { + validLng = lngA; + validLat = latA; + } else { + validLng = lngB; + validLat = latB; + } + } else if (isValidA) { + validLng = lngA; + validLat = latA; + } else if (isValidB) { + validLng = lngB; + validLat = latB; + } else { + // No solution. + return; + } + + const newLng = validLng / Math.PI * 180; + const newLat = validLat / Math.PI * 180; + const oldLat = this.center.lat; + this.setCenter(new LngLat(newLng, clamp(newLat, -90, 90))); + this.setZoom(this.zoom + getZoomAdjustment(oldLat, this.center.lat)); + } + + locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point { + if (!this._globeRendering) { + return this._mercatorTransform.locationToScreenPoint(lnglat, terrain); + } + + const pos = angularCoordinatesToSurfaceVector(lnglat); + + if (terrain) { + const elevation = terrain.getElevationForLngLatZoom(lnglat, this._helper._tileZoom); + vec3.scale(pos, pos, 1.0 + elevation / earthRadius); + } + + return this._projectSurfacePointToScreen(pos); + } + + /** + * Projects a given vector on the surface of a unit sphere (or possible above the surface) + * and returns its coordinates on screen in pixels. + */ + private _projectSurfacePointToScreen(pos: vec3): Point { + const projected = createVec4f64(); + vec4.transformMat4(projected, [...pos, 1] as vec4, this._globeViewProjMatrixNoCorrection); + projected[0] /= projected[3]; + projected[1] /= projected[3]; + return new Point( + (projected[0] * 0.5 + 0.5) * this.width, + (-projected[1] * 0.5 + 0.5) * this.height + ); + } + + screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate { + if (!this._globeRendering || terrain) { + // Mercator has terrain handling implemented properly and since terrain + // simply draws tile coordinates into a special framebuffer, this works well even for globe. + return this._mercatorTransform.screenPointToMercatorCoordinate(p, terrain); + } + return MercatorCoordinate.fromLngLat(this.unprojectScreenPoint(p)); + } + + screenPointToLocation(p: Point, terrain?: Terrain): LngLat { + if (!this._globeRendering || terrain) { + // Mercator has terrain handling implemented properly and since terrain + // simply draws tile coordinates into a special framebuffer, this works well even for globe. + return this._mercatorTransform.screenPointToLocation(p, terrain); + } + return this.unprojectScreenPoint(p); + } + + isPointOnMapSurface(p: Point, terrain?: Terrain): boolean { + if (!this._globeRendering) { + return this._mercatorTransform.isPointOnMapSurface(p, terrain); + } + + const rayOrigin = this._cameraPosition; + const rayDirection = this.getRayDirectionFromPixel(p); + + const intersection = this.rayPlanetIntersection(rayOrigin, rayDirection); + + return !!intersection; + } + + /** + * Computes normalized direction of a ray from the camera to the given screen pixel. + */ + getRayDirectionFromPixel(p: Point): vec3 { + const pos = createVec4f64(); + pos[0] = (p.x / this.width) * 2.0 - 1.0; + pos[1] = ((p.y / this.height) * 2.0 - 1.0) * -1.0; + pos[2] = 1; + pos[3] = 1; + vec4.transformMat4(pos, pos, this._globeViewProjMatrixNoCorrectionInverted); + pos[0] /= pos[3]; + pos[1] /= pos[3]; + pos[2] /= pos[3]; + const ray = createVec3f64(); + ray[0] = pos[0] - this._cameraPosition[0]; + ray[1] = pos[1] - this._cameraPosition[1]; + ray[2] = pos[2] - this._cameraPosition[2]; + const rayNormalized: vec3 = createVec3f64(); + vec3.normalize(rayNormalized, ray); + return rayNormalized; + } + + /** + * For a given point on the unit sphere of the planet, returns whether it is visible from + * camera's position (not taking into account camera rotation at all). + */ + private isSurfacePointVisible(p: vec3): boolean { + if (!this._globeRendering) { + return true; + } + const plane = this._cachedClippingPlane; + // dot(position on sphere, occlusion plane equation) + const dotResult = plane[0] * p[0] + plane[1] * p[1] + plane[2] * p[2] + plane[3]; + return dotResult >= 0.0; + } + + /** + * Returns whether surface point is visible on screen. + * It must both project to a pixel in screen bounds and not be occluded by the planet. + */ + private isSurfacePointOnScreen(vec: vec3): boolean { + if (!this.isSurfacePointVisible(vec)) { + return false; + } + + const projected = createVec4f64(); + vec4.transformMat4(projected, [...vec, 1] as vec4, this._globeViewProjMatrixNoCorrection); + projected[0] /= projected[3]; + projected[1] /= projected[3]; + projected[2] /= projected[3]; + return projected[0] > -1 && projected[0] < 1 && + projected[1] > -1 && projected[1] < 1 && + projected[2] > -1 && projected[2] < 1; + } + + /** + * Returns the two intersection points of the ray and the planet's sphere, + * or null if no intersection occurs. + * The intersections are encoded as the parameter for parametric ray equation, + * with `tMin` being the first intersection and `tMax` being the second. + * Eg. the nearer intersection point can then be computed as `origin + direction * tMin`. + * @param origin - The ray origin. + * @param direction - The normalized ray direction. + */ + private rayPlanetIntersection(origin: vec3, direction: vec3): RaySphereIntersection { + const originDotDirection = vec3.dot(origin, direction); + const planetRadiusSquared = 1.0; // planet is a unit sphere, so its radius squared is 1 + + // Ray-sphere intersection involves a quadratic equation. + // However solving it in the traditional schoolbook way leads to floating point precision issues. + // Here we instead use the approach suggested in the book Ray Tracing Gems, chapter 7. + // https://www.realtimerendering.com/raytracinggems/rtg/index.html + const inner = createVec3f64(); + const scaledDir = createVec3f64(); + vec3.scale(scaledDir, direction, originDotDirection); + vec3.sub(inner, origin, scaledDir); + const discriminant = planetRadiusSquared - vec3.dot(inner, inner); + + if (discriminant < 0) { + return null; + } + + const c = vec3.dot(origin, origin) - planetRadiusSquared; + const q = -originDotDirection + (originDotDirection < 0 ? 1 : -1) * Math.sqrt(discriminant); + const t0 = c / q; + const t1 = q; + // Assume the ray origin is never inside the sphere + const tMin = Math.min(t0, t1); + const tMax = Math.max(t0, t1); + return { + tMin, + tMax + }; + } + + /** + * @internal + * Returns a {@link LngLat} representing geographical coordinates that correspond to the specified pixel coordinates. + * Note: if the point does not lie on the globe, returns a location on the visible globe horizon (edge) that is + * as close to the point as possible. + * @param p - Screen point in pixels to unproject. + * @param terrain - Optional terrain. + */ + private unprojectScreenPoint(p: Point): LngLat { + // Here we compute the intersection of the ray towards the pixel at `p` and the planet sphere. + // As always, we assume that the planet is centered at 0,0,0 and has radius 1. + // Ray origin is `_cameraPosition` and direction is `rayNormalized`. + const rayOrigin = this._cameraPosition; + const rayDirection = this.getRayDirectionFromPixel(p); + const intersection = this.rayPlanetIntersection(rayOrigin, rayDirection); + + if (intersection) { + // Ray intersects the sphere -> compute intersection LngLat. + // Assume the ray origin is never inside the sphere - just use tMin + const intersectionPoint = createVec3f64(); + vec3.add(intersectionPoint, rayOrigin, [ + rayDirection[0] * intersection.tMin, + rayDirection[1] * intersection.tMin, + rayDirection[2] * intersection.tMin + ]); + const sphereSurface = createVec3f64(); + vec3.normalize(sphereSurface, intersectionPoint); + return sphereSurfacePointToCoordinates(sphereSurface); + } + + // Ray does not intersect the sphere -> find the closest point on the horizon to the ray. + // Intersect the ray with the clipping plane, since we know that the intersection of the clipping plane and the sphere is the horizon. + const directionDotPlaneXyz = this._cachedClippingPlane[0] * rayDirection[0] + this._cachedClippingPlane[1] * rayDirection[1] + this._cachedClippingPlane[2] * rayDirection[2]; + const originToPlaneDistance = pointPlaneSignedDistance(this._cachedClippingPlane, rayOrigin); + const distanceToIntersection = -originToPlaneDistance / directionDotPlaneXyz; + + const maxRayLength = 2.0; // One globe diameter + const planeIntersection = createVec3f64(); + + if (distanceToIntersection > 0) { + vec3.add(planeIntersection, rayOrigin, [ + rayDirection[0] * distanceToIntersection, + rayDirection[1] * distanceToIntersection, + rayDirection[2] * distanceToIntersection + ]); + } else { + // When the ray takes too long to hit the plane (>maxRayLength), or if the plane intersection is behind the camera, handle things differently. + // Take a point along the ray at distance maxRayLength, project it to clipping plane, then continue as normal to find the horizon point. + const distantPoint = createVec3f64(); + vec3.add(distantPoint, rayOrigin, [ + rayDirection[0] * maxRayLength, + rayDirection[1] * maxRayLength, + rayDirection[2] * maxRayLength + ]); + const distanceFromPlane = pointPlaneSignedDistance(this._cachedClippingPlane, distantPoint); + vec3.sub(planeIntersection, distantPoint, [ + this._cachedClippingPlane[0] * distanceFromPlane, + this._cachedClippingPlane[1] * distanceFromPlane, + this._cachedClippingPlane[2] * distanceFromPlane + ]); + } + + const closestOnHorizon = createVec3f64(); + vec3.normalize(closestOnHorizon, planeIntersection); + return sphereSurfacePointToCoordinates(closestOnHorizon); + } + + getMatrixForModel(location: LngLatLike, altitude?: number): mat4 { + if (!this._globeRendering) { + return this._mercatorTransform.getMatrixForModel(location, altitude); + } + const lnglat = LngLat.convert(location); + const scale = 1.0 / earthRadius; + + const m = createIdentityMat4f64(); + mat4.rotateY(m, m, lnglat.lng / 180.0 * Math.PI); + mat4.rotateX(m, m, -lnglat.lat / 180.0 * Math.PI); + mat4.translate(m, m, [0, 0, 1 + altitude / earthRadius]); + mat4.rotateX(m, m, Math.PI * 0.5); + mat4.scale(m, m, [scale, scale, scale]); + return m; + } + + getProjectionDataForCustomLayer(): ProjectionData { + const projectionData = this.getProjectionData(new OverscaledTileID(0, 0, 0, 0, 0)); + projectionData.tileMercatorCoords = [0, 0, 1, 1]; + + // Even though we requested projection data for the mercator base tile which covers the entire mercator range, + // the shader projection machinery still expects inputs to be in tile units range [0..EXTENT]. + // Since custom layers are expected to supply mercator coordinates [0..1], we need to rescale + // the fallback projection matrix by EXTENT. + // Note that the regular projection matrices do not need to be modified, since the rescaling happens by setting + // the `u_projection_tile_mercator_coords` uniform correctly. + const fallbackMatrixScaled = createMat4f64(); + mat4.scale(fallbackMatrixScaled, projectionData.fallbackMatrix, [EXTENT, EXTENT, 1]); + + projectionData.fallbackMatrix = new Float32Array(fallbackMatrixScaled); + return projectionData; + } + + getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 { + if (!this._globeRendering) { + return this._mercatorTransform.getFastPathSimpleProjectionMatrix(tileID); + } + return undefined; + } +} diff --git a/src/geo/projection/globe_utils.test.ts b/src/geo/projection/globe_utils.test.ts new file mode 100644 index 0000000000..2aacd0e8bf --- /dev/null +++ b/src/geo/projection/globe_utils.test.ts @@ -0,0 +1,49 @@ +import {LngLat} from '../lng_lat'; +import {getGlobeCircumferencePixels, getZoomAdjustment, globeDistanceOfLocationsPixels} from './globe_utils'; + +describe('globe utils', () => { + const digitsPrecision = 10; + + test('getGlobeCircumferencePixels', () => { + expect(getGlobeCircumferencePixels({ + worldSize: 1, + center: { + lat: 0 + } + })).toBeCloseTo(1, digitsPrecision); + expect(getGlobeCircumferencePixels({ + worldSize: 1, + center: { + lat: 60 + } + })).toBeCloseTo(2, digitsPrecision); + }); + + test('globeDistanceOfLocationsPixels', () => { + expect(globeDistanceOfLocationsPixels({ + worldSize: 1, + center: { + lat: 0 + } + }, new LngLat(0, 0), new LngLat(90, 0))).toBeCloseTo(0.25, digitsPrecision); + + expect(globeDistanceOfLocationsPixels({ + worldSize: 1, + center: { + lat: 0 + } + }, new LngLat(0, -45), new LngLat(0, 45))).toBeCloseTo(0.25, digitsPrecision); + + expect(globeDistanceOfLocationsPixels({ + worldSize: 1, + center: { + lat: 0 + } + }, new LngLat(0, 0), new LngLat(45, 45))).toBeCloseTo(0.16666666666666666, digitsPrecision); + }); + + test('getZoomAdjustment', () => { + expect(getZoomAdjustment(0, 60)).toBeCloseTo(-1, digitsPrecision); + expect(getZoomAdjustment(60, 0)).toBeCloseTo(1, digitsPrecision); + }); +}); diff --git a/src/geo/projection/globe_utils.ts b/src/geo/projection/globe_utils.ts new file mode 100644 index 0000000000..a6a4e5acde --- /dev/null +++ b/src/geo/projection/globe_utils.ts @@ -0,0 +1,217 @@ +import {vec3} from 'gl-matrix'; +import {clamp, differenceOfAnglesDegrees, lerp, mod, remapSaturate, wrap} from '../../util/util'; +import {LngLat} from '../lng_lat'; +import {MAX_VALID_LATITUDE, scaleZoom} from '../transform_helper'; +import Point from '@mapbox/point-geometry'; +import {EXTENT} from '../../data/extent'; + +export function getGlobeCircumferencePixels(transform: {worldSize: number; center: {lat: number}}): number { + const radius = getGlobeRadiusPixels(transform.worldSize, transform.center.lat); + const circumference = 2.0 * Math.PI * radius; + return circumference; +} + +export function globeDistanceOfLocationsPixels(transform: {worldSize: number; center: {lat: number}}, a: LngLat, b: LngLat): number { + const vecA = angularCoordinatesToSurfaceVector(a); + const vecB = angularCoordinatesToSurfaceVector(b); + const dot = vec3.dot(vecA, vecB); + const radians = Math.acos(dot); + const circumference = getGlobeCircumferencePixels(transform); + return radians / (2.0 * Math.PI) * circumference; +} + +/** + * For given mercator coordinates in range 0..1, returns the angular coordinates on the sphere's surface, in radians. + */ +export function mercatorCoordinatesToAngularCoordinatesRadians(mercatorX: number, mercatorY: number): [number, number] { + const sphericalX = mod(mercatorX * Math.PI * 2.0 + Math.PI, Math.PI * 2); + const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5; + return [sphericalX, sphericalY]; +} + +/** + * For a given longitude and latitude (note: in radians) returns the normalized vector from the planet center to the specified place on the surface. + * @param lngRadians - Longitude in radians. + * @param latRadians - Latitude in radians. + */ +export function angularCoordinatesRadiansToVector(lngRadians: number, latRadians: number): vec3 { + const len = Math.cos(latRadians); + const vec = new Float64Array(3) as any; + vec[0] = Math.sin(lngRadians) * len; + vec[1] = Math.sin(latRadians); + vec[2] = Math.cos(lngRadians) * len; + return vec; +} + +export function projectTileCoordinatesToSphere(inTileX: number, inTileY: number, tileIdX: number, tileIdY: number, tileIdZ: number): vec3 { + // This code could be assembled from 3 fuctions, but this is a hot path for symbol placement, + // so for optimization purposes everything is inlined by hand. + // + // Non-inlined variant of this function would be this: + // const mercator = tileCoordinatesToMercatorCoordinates(inTileX, inTileY, tileID); + // const angular = mercatorCoordinatesToAngularCoordinatesRadians(mercator.x, mercator.y); + // const sphere = angularCoordinatesRadiansToVector(angular[0], angular[1]); + // return sphere; + const scale = 1.0 / (1 << tileIdZ); + const mercatorX = inTileX / EXTENT * scale + tileIdX * scale; + const mercatorY = inTileY / EXTENT * scale + tileIdY * scale; + const sphericalX = mod(mercatorX * Math.PI * 2.0 + Math.PI, Math.PI * 2); + const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5; + const len = Math.cos(sphericalY); + const vec = new Float64Array(3) as any; + vec[0] = Math.sin(sphericalX) * len; + vec[1] = Math.sin(sphericalY); + vec[2] = Math.cos(sphericalX) * len; + return vec; +} + +/** + * For a given longitude and latitude (note: in degrees) returns the normalized vector from the planet center to the specified place on the surface. + */ +export function angularCoordinatesToSurfaceVector(lngLat: LngLat): vec3 { + return angularCoordinatesRadiansToVector(lngLat.lng * Math.PI / 180, lngLat.lat * Math.PI / 180); +} + +export function getGlobeRadiusPixels(worldSize: number, latitudeDegrees: number) { + // 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. + // For this reason we scale the globe up when map center is nearer to the poles. + return worldSize / (2.0 * Math.PI) / Math.cos(latitudeDegrees * Math.PI / 180); +} + +/** + * Given a 3D point on the surface of a unit sphere, returns its angular coordinates in degrees. + * The input vector must be normalized. + */ +export function sphereSurfacePointToCoordinates(surface: vec3): LngLat { + const latRadians = Math.asin(surface[1]); + const latDegrees = latRadians / Math.PI * 180.0; + const lengthXZ = Math.sqrt(surface[0] * surface[0] + surface[2] * surface[2]); + if (lengthXZ > 1e-6) { + const projX = surface[0] / lengthXZ; + const projZ = surface[2] / lengthXZ; + const acosZ = Math.acos(projZ); + const lngRadians = (projX > 0) ? acosZ : -acosZ; + const lngDegrees = lngRadians / Math.PI * 180.0; + return new LngLat(wrap(lngDegrees, -180, 180), latDegrees); + } else { + return new LngLat(0.0, latDegrees); + } +} + +function planetScaleAtLatitude(latitudeDegrees: number): number { + return Math.cos(latitudeDegrees * Math.PI / 180); +} + +/** + * Computes how much to modify zoom to keep the globe size constant when changing latitude. + * @param transform - An instance of any transform. Does not have any relation on the computed values. + * @param oldLat - Latitude before change, in degrees. + * @param newLat - Latitude after change, in degrees. + * @returns A value to add to zoom level used for old latitude to keep same planet radius at new latitude. + */ +export function getZoomAdjustment(oldLat: number, newLat: number): number { + const oldCircumference = planetScaleAtLatitude(oldLat); + const newCircumference = planetScaleAtLatitude(newLat); + return scaleZoom(newCircumference / oldCircumference); +} + +export function getDegreesPerPixel(worldSize: number, lat: number): number { + return 360.0 / getGlobeCircumferencePixels({worldSize, center: {lat}}); +} + +/** + * Returns transform's new center rotation after applying panning. + * @param panDelta - Panning delta, in same units as what is supplied to {@link HandlerManager}. + * @param tr - Current transform. This object is not modified by the function. + * @returns New center location to set to the map's transform to apply the specified panning. + */ +export function computeGlobePanCenter(panDelta: Point, tr: { + readonly angle: number; + readonly worldSize: number; + readonly center: LngLat; + readonly zoom: number; +}): LngLat { + // Apply map bearing to the panning vector + const rotatedPanDelta = panDelta.rotate(-tr.angle); + // Compute what the current zoom would be if the transform center would be moved to latitude 0. + const normalizedGlobeZoom = tr.zoom + getZoomAdjustment(tr.center.lat, 0); + // Note: we divide longitude speed by planet width at the given latitude. But we diminish this effect when the globe is zoomed out a lot. + const lngSpeed = lerp( + 1.0 / planetScaleAtLatitude(tr.center.lat), // speed adjusted by latitude + 1.0 / planetScaleAtLatitude(Math.min(Math.abs(tr.center.lat), 60)), // also adjusted, but latitude is clamped to 60° to avoid too large speeds near poles + remapSaturate(normalizedGlobeZoom, 7, 3, 0, 1.0) // Values chosen so that globe interactions feel good. Not scientific by any means. + ); + const panningDegreesPerPixel = getDegreesPerPixel(tr.worldSize, tr.center.lat); + return new LngLat( + tr.center.lng - rotatedPanDelta.x * panningDegreesPerPixel * lngSpeed, + clamp(tr.center.lat + rotatedPanDelta.y * panningDegreesPerPixel, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE) + ); +} + +/** + * Integration of `1 / cos(x)`. + */ +function integrateSecX(x: number): number { + const xHalf = 0.5 * x; + const sin = Math.sin(xHalf); + const cos = Math.cos(xHalf); + return Math.log(sin + cos) - Math.log(cos - sin); +} + +/** + * Interpolates globe center between two locations while preserving apparent rotation speed during interpolation. + * @param start - The starting location of the interpolation. + * @param deltaLng - Longitude delta to the end of the interpolation. + * @param deltaLat - Latitude delta to the end of the interpolation. + * @param t - The interpolation point in [0..1], where 0 is starting location, 1 is end location and other values are in between. + * @returns The interpolated location. + */ +export function interpolateLngLatForGlobe(start: LngLat, deltaLng: number, deltaLat: number, t: number): LngLat { + // Rate of change of longitude when moving the globe should be roughly 1/cos(latitude) + // We want to use this rate of change, even for interpolation during easing. + // Thus we know the derivative of our interpolation function: 1/cos(x) + // To get our interpolation function, we need to integrate that. + + const interpolatedLat = start.lat + deltaLat * t; + + if (Math.abs(deltaLat) > 1) { + const endLat = start.lat + deltaLat; + const onDifferentHemispheres = Math.sign(endLat) !== Math.sign(start.lat); + // Where do we sample the integrated speed curve? + const samplePointStart = (onDifferentHemispheres ? -Math.abs(start.lat) : Math.abs(start.lat)) * Math.PI / 180; + const samplePointEnd = Math.abs(start.lat + deltaLat) * Math.PI / 180; + // Read the integrated speed curve at those points, and at the interpolation value "t". + const valueT = integrateSecX(samplePointStart + t * (samplePointEnd - samplePointStart)); + const valueStart = integrateSecX(samplePointStart); + const valueEnd = integrateSecX(samplePointEnd); + // Compute new interpolation factor based on the speed curve + const newT = (valueT - valueStart) / (valueEnd - valueStart); + // Interpolate using that factor + const interpolatedLng = start.lng + deltaLng * newT; + return new LngLat( + interpolatedLng, + interpolatedLat + ); + } else { + // Fall back to simple interpolation when latitude doesn't change much. + const interpolatedLng = start.lng + deltaLng * t; + return new LngLat( + interpolatedLng, + interpolatedLat + ); + } +} + +export function clampLngLat(val: LngLat, min: LngLat, max: LngLat): LngLat { + const lat = clamp(val.lat, min.lat, max.lat); + if (val.lng >= min.lng && val.lng <= max.lng) { + return new LngLat(val.lng, lat); + } + if (Math.abs(differenceOfAnglesDegrees(min.lng, val.lng)) < Math.abs(differenceOfAnglesDegrees(max.lng, val.lng))) { + return new LngLat(min.lng, lat); + } else { + return new LngLat(max.lng, lat); + } +} diff --git a/src/geo/projection/mercator.ts b/src/geo/projection/mercator.ts new file mode 100644 index 0000000000..d9d4d9ac07 --- /dev/null +++ b/src/geo/projection/mercator.ts @@ -0,0 +1,87 @@ +import type {Projection, ProjectionGPUContext, TileMeshUsage} from './projection'; +import type {CanonicalTileID} from '../../source/tile_id'; +import {EXTENT} from '../../data/extent'; +import {PreparedShader, shaders} from '../../shaders/shaders'; +import type {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'; +import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; + +export const MercatorShaderDefine = '#define PROJECTION_MERCATOR'; +export const MercatorShaderVariantKey = 'mercator'; + +export class MercatorProjection implements Projection { + private _cachedMesh: Mesh = null; + + get name(): 'mercator' { + return 'mercator'; + } + + 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; + } + + get subdivisionGranularity(): SubdivisionGranularitySetting { + return SubdivisionGranularitySetting.noSubdivision; + } + + get useGlobeControls(): boolean { + return false; + } + + public destroy(): void { + // Do nothing. + } + + public isRenderingDirty(): boolean { + // Mercator projection does no animations of its own, so rendering is never dirty from its perspective. + return false; + } + + public updateGPUdependent(_: ProjectionGPUContext): void { + // Do nothing. + } + + public getMeshFromTileID(context: Context, _tileID: CanonicalTileID, _hasBorder: boolean, _allowPoles: boolean, _usage: TileMeshUsage): Mesh { + if (this._cachedMesh) { + return this._cachedMesh; + } + + // The parameters tileID, hasBorder and allowPoles are all ignored on purpose for mercator meshes. + + 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; + } +} diff --git a/src/geo/projection/mercator_camera_helper.ts b/src/geo/projection/mercator_camera_helper.ts new file mode 100644 index 0000000000..0d9a105aa9 --- /dev/null +++ b/src/geo/projection/mercator_camera_helper.ts @@ -0,0 +1,231 @@ +import Point from '@mapbox/point-geometry'; +import {LngLat, LngLatLike} from '../lng_lat'; +import {IReadonlyTransform, ITransform} from '../transform_interface'; +import {cameraBoundsWarning, CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas} from './camera_helper'; +import {CameraForBoundsOptions} from '../../ui/camera'; +import {PaddingOptions} from '../edge_insets'; +import {LngLatBounds} from '../lng_lat_bounds'; +import {normalizeCenter, scaleZoom, zoomScale} from '../transform_helper'; +import {degreesToRadians} from '../../util/util'; +import {projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils'; +import {interpolates} from '@maplibre/maplibre-gl-style-spec'; + +/** + * @internal + */ +export class MercatorCameraHelper implements ICameraHelper { + get useGlobeControls(): boolean { return false; } + + handlePanInertia(pan: Point, transform: IReadonlyTransform): { + easingCenter: LngLat; + easingOffset: Point; + } { + return { + easingOffset: pan, + easingCenter: transform.center, + }; + } + + handleMapControlsPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { + if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta); + if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta); + if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta); + } + + handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void { + tr.setLocationAtPoint(preZoomAroundLoc, deltas.around); + } + + cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult { + const edgePadding = tr.padding; + + // Consider all corners of the rotated bounding box derived from the given points + // when find the camera position that fits the given points. + + const nwWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthWest()); + const neWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthEast()); + const seWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthEast()); + const swWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthWest()); + + const bearingRadians = degreesToRadians(-bearing); + + const nwRotatedWorld = nwWorld.rotate(bearingRadians); + const neRotatedWorld = neWorld.rotate(bearingRadians); + const seRotatedWorld = seWorld.rotate(bearingRadians); + const swRotatedWorld = swWorld.rotate(bearingRadians); + + const upperRight = new Point( + Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), + Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) + ); + + const lowerLeft = new Point( + Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), + Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) + ); + + // Calculate zoom: consider the original bbox and padding. + const size = upperRight.sub(lowerLeft); + + const availableWidth = (tr.width - (edgePadding.left + edgePadding.right + padding.left + padding.right)); + const availableHeight = (tr.height - (edgePadding.top + edgePadding.bottom + padding.top + padding.bottom)); + const scaleX = availableWidth / size.x; + const scaleY = availableHeight / size.y; + + if (scaleY < 0 || scaleX < 0) { + cameraBoundsWarning(); + return undefined; + } + + const zoom = Math.min(scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom); + + // Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding. + const offset = Point.convert(options.offset); + const paddingOffsetX = (padding.left - padding.right) / 2; + const paddingOffsetY = (padding.top - padding.bottom) / 2; + const paddingOffset = new Point(paddingOffsetX, paddingOffsetY); + const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing)); + const offsetAtInitialZoom = offset.add(rotatedPaddingOffset); + const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / zoomScale(zoom)); + + const center = unprojectFromWorldCoordinates( + tr.worldSize, + // either world diagonal can be used (NW-SE or NE-SW) + nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom) + ); + + const result = { + center, + zoom, + bearing + }; + + return result; + } + + handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void { + // Mercator zoom & center handling. + const optionsZoom = typeof options.zoom !== 'undefined'; + + const zoom = optionsZoom ? +options.zoom : tr.zoom; + if (tr.zoom !== zoom) { + tr.setZoom(+options.zoom); + } + + if (options.center !== undefined) { + tr.setCenter(LngLat.convert(options.center)); + } + } + + handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult { + const startZoom = tr.zoom; + const startBearing = tr.bearing; + const startPitch = tr.pitch; + const startPadding = tr.padding; + + const optionsZoom = typeof options.zoom !== 'undefined'; + + const doPadding = !tr.isPaddingEqual(options.padding); + + let isZooming = false; + + const zoom = optionsZoom ? +options.zoom : tr.zoom; + + let pointAtOffset = tr.centerPoint.add(options.offsetAsPoint); + const locationAtOffset = tr.screenPointToLocation(pointAtOffset); + const {center, zoom: endZoom} = tr.getConstrained( + LngLat.convert(options.center || locationAtOffset), + zoom ?? startZoom + ); + normalizeCenter(tr, center); + + const from = projectToWorldCoordinates(tr.worldSize, locationAtOffset); + const delta = projectToWorldCoordinates(tr.worldSize, center).sub(from); + + const finalScale = zoomScale(endZoom - startZoom); + isZooming = (endZoom !== startZoom); + + const easeFunc = (k: number) => { + if (isZooming) { + tr.setZoom(interpolates.number(startZoom, endZoom, k)); + } + if (startBearing !== options.bearing) { + tr.setBearing(interpolates.number(startBearing, options.bearing, k)); + } + if (startPitch !== options.pitch) { + tr.setPitch(interpolates.number(startPitch, options.pitch, k)); + } + if (doPadding) { + tr.interpolatePadding(startPadding, options.padding, k); + // When padding is being applied, Transform#centerPoint is changing continuously, + // thus we need to recalculate offsetPoint every frame + pointAtOffset = tr.centerPoint.add(options.offsetAsPoint); + } + + if (options.around) { + tr.setLocationAtPoint(options.around, options.aroundPoint); + } else { + const scale = zoomScale(tr.zoom - startZoom); + const base = endZoom > startZoom ? + Math.min(2, finalScale) : + Math.max(0.5, finalScale); + const speedup = Math.pow(base, 1 - k); + const newCenter = unprojectFromWorldCoordinates(tr.worldSize, from.add(delta.mult(k * speedup)).mult(scale)); + tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); + } + }; + + return { + easeFunc, + isZooming, + elevationCenter: center, + }; + } + + handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult { + const optionsZoom = typeof options.zoom !== 'undefined'; + + const startZoom = tr.zoom; + + // Obtain target center and zoom + const constrained = tr.getConstrained( + LngLat.convert(options.center || options.locationAtOffset), + optionsZoom ? +options.zoom : startZoom + ); + const targetCenter = constrained.center; + const targetZoom = constrained.zoom; + + normalizeCenter(tr, targetCenter); + + const from = projectToWorldCoordinates(tr.worldSize, options.locationAtOffset); + const delta = projectToWorldCoordinates(tr.worldSize, targetCenter).sub(from); + + const pixelPathLength = delta.mag(); + + const scaleOfZoom = zoomScale(targetZoom - startZoom); + + const optionsMinZoom = typeof options.minZoom !== 'undefined'; + + let scaleOfMinZoom: number; + + if (optionsMinZoom) { + const minZoomPreConstrain = Math.min(+options.minZoom, startZoom, targetZoom); + const minZoom = tr.getConstrained(targetCenter, minZoomPreConstrain).zoom; + scaleOfMinZoom = zoomScale(minZoom - startZoom); + } + + const easeFunc = (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => { + tr.setZoom(k === 1 ? targetZoom : startZoom + scaleZoom(scale)); + const newCenter = k === 1 ? targetCenter : unprojectFromWorldCoordinates(tr.worldSize, from.add(delta.mult(centerFactor)).mult(scale)); + tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); + }; + + return { + easeFunc, + scaleOfZoom, + targetCenter, + scaleOfMinZoom, + pixelPathLength, + }; + } +} diff --git a/src/geo/projection/mercator_covering_tiles.ts b/src/geo/projection/mercator_covering_tiles.ts new file mode 100644 index 0000000000..8b919e91ab --- /dev/null +++ b/src/geo/projection/mercator_covering_tiles.ts @@ -0,0 +1,140 @@ +import {mat4, vec2, vec3} from 'gl-matrix'; +import {OverscaledTileID} from '../../source/tile_id'; +import {Aabb, Frustum, IntersectionResult} from '../../util/primitives'; +import {MercatorCoordinate} from '../mercator_coordinate'; +import {CoveringTilesOptions, IReadonlyTransform} from '../transform_interface'; + +type CoveringTilesResult = { + tileID: OverscaledTileID; + distanceSq: number; + tileDistanceToCamera: number; +}; + +type CoveringTilesStackEntry = { + aabb: Aabb; + zoom: number; + x: number; + y: number; + wrap: number; + fullyVisible: boolean; +}; + +/** + * Returns a list of tiles that optimally covers the screen. + * Correctly handles LOD when moving over the antimeridian. + * @param transform - The mercator transform instance. + * @param options - Additional coveringTiles options. + * @param invViewProjMatrix - Inverse view projection matrix, for computing camera frustum. + * @returns A list of tile coordinates, ordered by ascending distance from camera. + */ +export function mercatorCoveringTiles(transform: IReadonlyTransform, options: CoveringTilesOptions, invViewProjMatrix: mat4): Array { + let z = transform.coveringZoomLevel(options); + const actualZ = z; + + if (options.minzoom !== undefined && z < options.minzoom) return []; + if (options.maxzoom !== undefined && z > options.maxzoom) z = options.maxzoom; + + const cameraCoord = transform.screenPointToMercatorCoordinate(transform.getCameraPoint()); + const centerCoord = MercatorCoordinate.fromLngLat(transform.center); + const numTiles = Math.pow(2, z); + const cameraPoint = [numTiles * cameraCoord.x, numTiles * cameraCoord.y, 0]; + const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0]; + const cameraFrustum = Frustum.fromInvProjectionMatrix(invViewProjMatrix, transform.worldSize, z); + + // No change of LOD behavior for pitch lower than 60 and when there is no top padding: return only tile ids from the requested zoom level + let minZoom = options.minzoom || 0; + // Use 0.1 as an epsilon to avoid for explicit == 0.0 floating point checks + if (!options.terrain && transform.pitch <= 60.0 && transform.padding.top < 0.1) + minZoom = z; + + // There should always be a certain number of maximum zoom level tiles surrounding the center location in 2D or in front of the camera in 3D + const radiusOfMaxLvlLodInTiles = options.terrain ? 2 / Math.min(transform.tileSize, options.tileSize) * transform.tileSize : 3; + + const newRootTile = (wrap: number): any => { + return { + aabb: new Aabb([wrap * numTiles, 0, 0], [(wrap + 1) * numTiles, numTiles, 0]), + zoom: 0, + x: 0, + y: 0, + wrap, + fullyVisible: false + }; + }; + + // Do a depth-first traversal to find visible tiles and proper levels of detail + const stack: Array = []; + const result: Array = []; + const maxZoom = z; + const overscaledZ = options.reparseOverscaled ? actualZ : z; + + if (transform.renderWorldCopies) { + // Render copy of the globe thrice on both sides + for (let i = 1; i <= 3; i++) { + stack.push(newRootTile(-i)); + stack.push(newRootTile(i)); + } + } + + stack.push(newRootTile(0)); + + while (stack.length > 0) { + const it = stack.pop(); + const x = it.x; + const y = it.y; + let fullyVisible = it.fullyVisible; + + // Visibility of a tile is not required if any of its ancestor if fully inside the frustum + if (!fullyVisible) { + const intersectResult = it.aabb.intersectsFrustum(cameraFrustum); + + if (intersectResult === IntersectionResult.None) + continue; + + fullyVisible = intersectResult === IntersectionResult.Full; + } + + const refPoint = options.terrain ? cameraPoint : centerPoint; + const distanceX = it.aabb.distanceX(refPoint); + const distanceY = it.aabb.distanceY(refPoint); + const longestDim = Math.max(Math.abs(distanceX), Math.abs(distanceY)); + + // We're using distance based heuristics to determine if a tile should be split into quadrants or not. + // radiusOfMaxLvlLodInTiles defines that there's always a certain number of maxLevel tiles next to the map center. + // Using the fact that a parent node in quadtree is twice the size of its children (per dimension) + // we can define distance thresholds for each relative level: + // f(k) = offset + 2 + 4 + 8 + 16 + ... + 2^k. This is the same as "offset+2^(k+1)-2" + const distToSplit = radiusOfMaxLvlLodInTiles + (1 << (maxZoom - it.zoom)) - 2; + + // Have we reached the target depth or is the tile too far away to be any split further? + if (it.zoom === maxZoom || (longestDim > distToSplit && it.zoom >= minZoom)) { + const dz = maxZoom - it.zoom, dx = cameraPoint[0] - 0.5 - (x << dz), dy = cameraPoint[1] - 0.5 - (y << dz); + result.push({ + tileID: new OverscaledTileID(it.zoom === maxZoom ? overscaledZ : it.zoom, it.wrap, it.zoom, x, y), + distanceSq: vec2.sqrLen([centerPoint[0] - 0.5 - x, centerPoint[1] - 0.5 - y]), + // this variable is currently not used, but may be important to reduce the amount of loaded tiles + tileDistanceToCamera: Math.sqrt(dx * dx + dy * dy) + }); + continue; + } + + for (let i = 0; i < 4; i++) { + const childX = (x << 1) + (i % 2); + const childY = (y << 1) + (i >> 1); + const childZ = it.zoom + 1; + let quadrant = it.aabb.quadrant(i); + if (options.terrain) { + const tileID = new OverscaledTileID(childZ, it.wrap, childZ, childX, childY); + const minMax = options.terrain.getMinMaxElevation(tileID); + const minElevation = minMax.minElevation ?? transform.elevation; + const maxElevation = minMax.maxElevation ?? transform.elevation; + quadrant = new Aabb( + [quadrant.min[0], quadrant.min[1], minElevation] as vec3, + [quadrant.max[0], quadrant.max[1], maxElevation] as vec3 + ); + } + stack.push({aabb: quadrant, zoom: childZ, x: childX, y: childY, wrap: it.wrap, fullyVisible}); + } + } + + return result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID); +} diff --git a/src/geo/transform.test.ts b/src/geo/projection/mercator_transform.test.ts similarity index 59% rename from src/geo/transform.test.ts rename to src/geo/projection/mercator_transform.test.ts index 402e391f2d..d6caa38226 100644 --- a/src/geo/transform.test.ts +++ b/src/geo/projection/mercator_transform.test.ts @@ -1,13 +1,17 @@ import Point from '@mapbox/point-geometry'; -import {MAX_VALID_LATITUDE, Transform} from './transform'; -import {LngLat} from './lng_lat'; -import {OverscaledTileID, CanonicalTileID} from '../source/tile_id'; -import {fixedLngLat, fixedCoord} from '../../test/unit/lib/fixed'; -import type {Terrain} from '../render/terrain'; +import {LngLat} from '../lng_lat'; +import {OverscaledTileID, CanonicalTileID, UnwrappedTileID} from '../../source/tile_id'; +import {fixedLngLat, fixedCoord} from '../../../test/unit/lib/fixed'; +import type {Terrain} from '../../render/terrain'; +import {MercatorTransform} from './mercator_transform'; +import {LngLatBounds} from '../lng_lat_bounds'; +import {getMercatorHorizon} from './mercator_utils'; +import {mat4} from 'gl-matrix'; +import {expectToBeCloseToArray} from '../../util/test/util'; describe('transform', () => { test('creates a transform', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(500, 500); expect(transform.unmodified).toBe(true); expect(transform.tileSize).toBe(512); @@ -17,133 +21,139 @@ describe('transform', () => { expect(transform.minPitch).toBe(0); // Support signed zero expect(transform.bearing === 0 ? 0 : transform.bearing).toBe(0); - expect(transform.bearing = 1).toBe(1); + transform.setBearing(1); expect(transform.bearing).toBe(1); - expect(transform.bearing = 0).toBe(0); + expect([...transform.rotationMatrix.values()]).toEqual([0.9998477101325989, -0.017452405765652657, 0.017452405765652657, 0.9998477101325989]); + transform.setBearing(0); + expect(transform.bearing).toBe(0); expect(transform.unmodified).toBe(false); - expect(transform.minZoom = 10).toBe(10); - expect(transform.maxZoom = 10).toBe(10); + transform.setMinZoom(10); + expect(transform.minZoom).toBe(10); + transform.setMaxZoom(10); + expect(transform.maxZoom).toBe(10); expect(transform.minZoom).toBe(10); expect(transform.center).toEqual({lng: 0, lat: 0}); expect(transform.maxZoom).toBe(10); - expect(transform.minPitch = 10).toBe(10); - expect(transform.maxPitch = 10).toBe(10); + transform.setMinPitch(10); + expect(transform.minPitch).toBe(10); + transform.setMaxPitch(10); + expect(transform.maxPitch).toBe(10); expect(transform.size.equals(new Point(500, 500))).toBe(true); expect(transform.centerPoint.equals(new Point(250, 250))).toBe(true); - expect(transform.scaleZoom(0)).toBe(-Infinity); - expect(transform.scaleZoom(10)).toBe(3.3219280948873626); - expect(transform.point).toEqual(new Point(262144, 262144)); expect(transform.height).toBe(500); expect(transform.nearZ).toBe(10); expect(transform.farZ).toBe(804.8028169246645); expect([...transform.projectionMatrix.values()]).toEqual([3, 0, 0, 0, 0, 3, 0, 0, -0, 0, -1.0251635313034058, -1, 0, 0, -20.25163459777832, 0]); + expectToBeCloseToArray([...transform.inverseProjectionMatrix.values()], [0.3333333333333333, 0, 0, 0, 0, 0.3333333333333333, 0, 0, 0, 0, 0, -0.04937872980873673, 0, 0, -1, 0.05062127019126326], 10); + expectToBeCloseToArray([...mat4.multiply(new Float64Array(16) as any, transform.projectionMatrix, transform.inverseProjectionMatrix).values()], [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1], 6); expect([...transform.modelViewProjectionMatrix.values()]).toEqual([3, 0, 0, 0, 0, -2.954423259036624, -0.1780177690666898, -0.17364817766693033, 0, 0.006822967915294533, -0.013222891287479163, -0.012898324631281611, -786432, 774484.3308168967, 47414.91102496082, 46270.827886319785]); - expect(fixedLngLat(transform.pointLocation(new Point(250, 250)))).toEqual({lng: 0, lat: 0}); - expect(fixedCoord(transform.pointCoordinate(new Point(250, 250)))).toEqual({x: 0.5, y: 0.5, z: 0}); - expect(transform.locationPoint(new LngLat(0, 0))).toEqual({x: 250, y: 250}); - expect(transform.locationCoordinate(new LngLat(0, 0))).toEqual({x: 0.5, y: 0.5, z: 0}); + expect(fixedLngLat(transform.screenPointToLocation(new Point(250, 250)))).toEqual({lng: 0, lat: 0}); + expect(fixedCoord(transform.screenPointToMercatorCoordinate(new Point(250, 250)))).toEqual({x: 0.5, y: 0.5, z: 0}); + expect(transform.locationToScreenPoint(new LngLat(0, 0))).toEqual({x: 250, y: 250}); }); test('does not throw on bad center', () => { expect(() => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(500, 500); - transform.center = new LngLat(50, -90); + transform.setCenter(new LngLat(50, -90)); }).not.toThrow(); }); test('setLocationAt', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(500, 500); - transform.zoom = 4; + transform.setZoom(4); expect(transform.center).toEqual({lng: 0, lat: 0}); transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45)); - expect(fixedLngLat(transform.pointLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10}); + expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10}); }); test('setLocationAt tilted', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(500, 500); - transform.zoom = 4; - transform.pitch = 50; + transform.setZoom(4); + transform.setPitch(50); expect(transform.center).toEqual({lng: 0, lat: 0}); transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45)); - expect(fixedLngLat(transform.pointLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10}); + expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10}); }); test('has a default zoom', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(500, 500); expect(transform.tileZoom).toBe(0); expect(transform.tileZoom).toBe(transform.zoom); }); test('set zoom inits tileZoom with zoom value', () => { - const transform = new Transform(0, 22, 0, 60); - transform.zoom = 5; + const transform = new MercatorTransform(0, 22, 0, 60); + transform.setZoom(5); expect(transform.tileZoom).toBe(5); }); test('set zoom clamps tileZoom to non negative value ', () => { - const transform = new Transform(-2, 22, 0, 60); - transform.zoom = -2; + const transform = new MercatorTransform(-2, 22, 0, 60); + transform.setZoom(-2); expect(transform.tileZoom).toBe(0); }); test('set fov', () => { - const transform = new Transform(0, 22, 0, 60, true); - transform.fov = 10; + const transform = new MercatorTransform(0, 22, 0, 60, true); + transform.setFov(10); expect(transform.fov).toBe(10); - transform.fov = 10; + transform.setFov(10); expect(transform.fov).toBe(10); }); test('lngRange & latRange constrain zoom and center', () => { - const transform = new Transform(0, 22, 0, 60, true); - transform.center = new LngLat(0, 0); - transform.zoom = 10; + const transform = new MercatorTransform(0, 22, 0, 60, true); + transform.setCenter(new LngLat(0, 0)); + transform.setZoom(10); transform.resize(500, 500); - transform.lngRange = [-5, 5]; - transform.latRange = [-5, 5]; + transform.setMaxBounds(new LngLatBounds([-5, -5, 5, 5])); - transform.zoom = 0; + transform.setZoom(0); expect(transform.zoom).toBe(5.1357092861044045); - transform.center = new LngLat(-50, -30); + transform.setCenter(new LngLat(-50, -30)); expect(transform.center).toEqual(new LngLat(0, -0.0063583052861417855)); - transform.zoom = 10; - transform.center = new LngLat(-50, -30); + transform.setZoom(10); + transform.setCenter(new LngLat(-50, -30)); expect(transform.center).toEqual(new LngLat(-4.828338623046875, -4.828969771321582)); }); test('lngRange & latRange constrain zoom and center after cloning', () => { - const old = new Transform(0, 22, 0, 60, true); - old.center = new LngLat(0, 0); - old.zoom = 10; + const old = new MercatorTransform(0, 22, 0, 60, true); + old.setCenter(new LngLat(0, 0)); + old.setZoom(10); old.resize(500, 500); - old.lngRange = [-5, 5]; - old.latRange = [-5, 5]; + old.setMaxBounds(new LngLatBounds([-5, -5, 5, 5])); const transform = old.clone(); - transform.zoom = 0; + transform.setZoom(0); expect(transform.zoom).toBe(5.1357092861044045); - transform.center = new LngLat(-50, -30); + transform.setCenter(new LngLat(-50, -30)); expect(transform.center).toEqual(new LngLat(0, -0.0063583052861417855)); - transform.zoom = 10; - transform.center = new LngLat(-50, -30); + transform.setZoom(10); + transform.setCenter(new LngLat(-50, -30)); expect(transform.center).toEqual(new LngLat(-4.828338623046875, -4.828969771321582)); }); test('lngRange can constrain zoom and center across meridian', () => { - const transform = new Transform(0, 22, 0, 60, true); - transform.center = new LngLat(180, 0); - transform.zoom = 10; + const transform = new MercatorTransform(0, 22, 0, 60, true); + transform.setCenter(new LngLat(180, 0)); + transform.setZoom(10); transform.resize(500, 500); // equivalent ranges @@ -152,23 +162,22 @@ describe('transform', () => { ]; for (const lngRange of lngRanges) { - transform.lngRange = lngRange; - transform.latRange = [-5, 5]; + transform.setMaxBounds(new LngLatBounds([lngRange[0], -5, lngRange[1], 5])); - transform.zoom = 0; + transform.setZoom(0); expect(transform.zoom).toBe(5.1357092861044045); - transform.center = new LngLat(-50, -30); + transform.setCenter(new LngLat(-50, -30)); expect(transform.center).toEqual(new LngLat(180, -0.0063583052861417855)); - transform.zoom = 10; - transform.center = new LngLat(-50, -30); + transform.setZoom(10); + transform.setCenter(new LngLat(-50, -30)); expect(transform.center).toEqual(new LngLat(-175.171661376953125, -4.828969771321582)); - transform.center = new LngLat(230, 0); + transform.setCenter(new LngLat(230, 0)); expect(transform.center).toEqual(new LngLat(-175.171661376953125, 0)); - transform.center = new LngLat(130, 0); + transform.setCenter(new LngLat(130, 0)); expect(transform.center).toEqual(new LngLat(175.171661376953125, 0)); } }); @@ -180,49 +189,49 @@ describe('transform', () => { tileSize: 512 }; - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(200, 200); test('general', () => { // make slightly off center so that sort order is not subject to precision issues - transform.center = new LngLat(-0.01, 0.01); + transform.setCenter(new LngLat(-0.01, 0.01)); - transform.zoom = 0; + transform.setZoom(0); expect(transform.coveringTiles(options)).toEqual([]); - transform.zoom = 1; + transform.setZoom(1); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(1, 0, 1, 0, 0), new OverscaledTileID(1, 0, 1, 1, 0), new OverscaledTileID(1, 0, 1, 0, 1), new OverscaledTileID(1, 0, 1, 1, 1)]); - transform.zoom = 2.4; + transform.setZoom(2.4); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(2, 0, 2, 1, 1), new OverscaledTileID(2, 0, 2, 2, 1), new OverscaledTileID(2, 0, 2, 1, 2), new OverscaledTileID(2, 0, 2, 2, 2)]); - transform.zoom = 10; + transform.setZoom(10); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(10, 0, 10, 511, 511), new OverscaledTileID(10, 0, 10, 512, 511), new OverscaledTileID(10, 0, 10, 511, 512), new OverscaledTileID(10, 0, 10, 512, 512)]); - transform.zoom = 11; + transform.setZoom(11); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(10, 0, 10, 511, 511), new OverscaledTileID(10, 0, 10, 512, 511), new OverscaledTileID(10, 0, 10, 511, 512), new OverscaledTileID(10, 0, 10, 512, 512)]); - transform.zoom = 5.1; - transform.pitch = 60.0; - transform.bearing = 32.0; - transform.center = new LngLat(56.90, 48.20); + transform.setZoom(5.1); + transform.setPitch(60.0); + transform.setBearing(32.0); + transform.setCenter(new LngLat(56.90, 48.20)); transform.resize(1024, 768); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(5, 0, 5, 21, 11), @@ -248,10 +257,10 @@ describe('transform', () => { new OverscaledTileID(5, 0, 5, 22, 7) ]); - transform.zoom = 8; - transform.pitch = 60; - transform.bearing = 45.0; - transform.center = new LngLat(25.02, 60.15); + transform.setZoom(8); + transform.setPitch(60); + transform.setBearing(45.0); + transform.setCenter(new LngLat(25.02, 60.15)); transform.resize(300, 50); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(8, 0, 8, 145, 74), @@ -267,14 +276,14 @@ describe('transform', () => { new OverscaledTileID(8, 0, 8, 146, 73) ]); - transform.zoom = 2; - transform.pitch = 0; - transform.bearing = 0; + transform.setZoom(2); + transform.setPitch(0); + transform.setBearing(0); transform.resize(300, 300); }); test('calculates tile coverage at w > 0', () => { - transform.center = new LngLat(630.01, 0.01); + transform.setCenter(new LngLat(630.01, 0.01)); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(2, 2, 2, 1, 1), new OverscaledTileID(2, 2, 2, 1, 2), @@ -284,7 +293,7 @@ describe('transform', () => { }); test('calculates tile coverage at w = -1', () => { - transform.center = new LngLat(-360.01, 0.01); + transform.setCenter(new LngLat(-360.01, 0.01)); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(2, -1, 2, 1, 1), new OverscaledTileID(2, -1, 2, 1, 2), @@ -294,8 +303,8 @@ describe('transform', () => { }); test('calculates tile coverage across meridian', () => { - transform.zoom = 1; - transform.center = new LngLat(-180.01, 0.01); + transform.setZoom(1); + transform.setCenter(new LngLat(-180.01, 0.01)); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(1, 0, 1, 0, 0), new OverscaledTileID(1, 0, 1, 0, 1), @@ -305,9 +314,9 @@ describe('transform', () => { }); test('only includes tiles for a single world, if renderWorldCopies is set to false', () => { - transform.zoom = 1; - transform.center = new LngLat(-180.01, 0.01); - transform.renderWorldCopies = false; + transform.setZoom(1); + transform.setCenter(new LngLat(-180.01, 0.01)); + transform.setRenderWorldCopies(false); expect(transform.coveringTiles(options)).toEqual([ new OverscaledTileID(1, 0, 1, 0, 0), new OverscaledTileID(1, 0, 1, 0, 1) @@ -324,50 +333,50 @@ describe('transform', () => { roundZoom: false, }; - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); - transform.zoom = 0; + transform.setZoom(0); expect(transform.coveringZoomLevel(options)).toBe(0); - transform.zoom = 0.1; + transform.setZoom(0.1); expect(transform.coveringZoomLevel(options)).toBe(0); - transform.zoom = 1; + transform.setZoom(1); expect(transform.coveringZoomLevel(options)).toBe(1); - transform.zoom = 2.4; + transform.setZoom(2.4); expect(transform.coveringZoomLevel(options)).toBe(2); - transform.zoom = 10; + transform.setZoom(10); expect(transform.coveringZoomLevel(options)).toBe(10); - transform.zoom = 11; + transform.setZoom(11); expect(transform.coveringZoomLevel(options)).toBe(11); - transform.zoom = 11.5; + transform.setZoom(11.5); expect(transform.coveringZoomLevel(options)).toBe(11); options.tileSize = 256; - transform.zoom = 0; + transform.setZoom(0); expect(transform.coveringZoomLevel(options)).toBe(1); - transform.zoom = 0.1; + transform.setZoom(0.1); expect(transform.coveringZoomLevel(options)).toBe(1); - transform.zoom = 1; + transform.setZoom(1); expect(transform.coveringZoomLevel(options)).toBe(2); - transform.zoom = 2.4; + transform.setZoom(2.4); expect(transform.coveringZoomLevel(options)).toBe(3); - transform.zoom = 10; + transform.setZoom(10); expect(transform.coveringZoomLevel(options)).toBe(11); - transform.zoom = 11; + transform.setZoom(11); expect(transform.coveringZoomLevel(options)).toBe(12); - transform.zoom = 11.5; + transform.setZoom(11.5); expect(transform.coveringZoomLevel(options)).toBe(12); options.roundZoom = true; @@ -375,67 +384,60 @@ describe('transform', () => { expect(transform.coveringZoomLevel(options)).toBe(13); }); - test('clamps latitude', () => { - const transform = new Transform(0, 22, 0, 60, true); - - expect(transform.project(new LngLat(0, -90))).toEqual(transform.project(new LngLat(0, -MAX_VALID_LATITUDE))); - expect(transform.project(new LngLat(0, 90))).toEqual(transform.project(new LngLat(0, MAX_VALID_LATITUDE))); - }); - test('clamps pitch', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); - transform.pitch = 45; + transform.setPitch(45); expect(transform.pitch).toBe(45); - transform.pitch = -10; + transform.setPitch(-10); expect(transform.pitch).toBe(0); - transform.pitch = 90; + transform.setPitch(90); expect(transform.pitch).toBe(60); }); test('visibleUnwrappedCoordinates', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(200, 200); - transform.zoom = 0; - transform.center = new LngLat(-170.01, 0.01); + transform.setZoom(0); + transform.setCenter(new LngLat(-170.01, 0.01)); let unwrappedCoords = transform.getVisibleUnwrappedCoordinates(new CanonicalTileID(0, 0, 0)); expect(unwrappedCoords).toHaveLength(4); //getVisibleUnwrappedCoordinates should honor _renderWorldCopies - transform._renderWorldCopies = false; + transform.setRenderWorldCopies(false); unwrappedCoords = transform.getVisibleUnwrappedCoordinates(new CanonicalTileID(0, 0, 0)); expect(unwrappedCoords).toHaveLength(1); }); test('maintains high float precision when calculating matrices', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(200.25, 200.25); - transform.zoom = 20.25; - transform.pitch = 67.25; - transform.center = new LngLat(0.0, 0.0); - transform._calcMatrices(); + transform.setZoom(20.25); + transform.setPitch(67.25); + transform.setCenter(new LngLat(0.0, 0.0)); - expect(transform.customLayerMatrix()[0].toString().length).toBeGreaterThan(10); - expect(transform.glCoordMatrix[0].toString().length).toBeGreaterThan(10); + const customLayerMatrix = transform.getProjectionDataForCustomLayer().mainMatrix; + expect(customLayerMatrix[0].toString().length).toBeGreaterThan(9); + expect(transform.pixelsToClipSpaceMatrix[0].toString().length).toBeGreaterThan(9); expect(transform.maxPitchScaleFactor()).toBeCloseTo(2.366025418080343, 5); }); test('recalculateZoom', () => { - const transform = new Transform(0, 22, 0, 60, true); - transform.elevation = 200; - transform.center = new LngLat(10.0, 50.0); - transform.zoom = 14; - transform.pitch = 45; + const transform = new MercatorTransform(0, 22, 0, 60, true); + transform.setElevation(200); + transform.setCenter(new LngLat(10.0, 50.0)); + transform.setZoom(14); + transform.setPitch(45); transform.resize(512, 512); // This should be an invariant throughout - the zoom is greater when the camera is // closer to the terrain (and therefore also when the terrain is closer to the camera), // but that shouldn't change the camera's position in world space if that wasn't requested. const expectedAltitude = 1865.7579397718; - expect(transform.getCameraPosition().altitude).toBeCloseTo(expectedAltitude, 10); + expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10); // expect same values because of no elevation change const terrain = { @@ -443,67 +445,76 @@ describe('transform', () => { pointCoordinate: () => null }; transform.recalculateZoom(terrain as any); - expect(transform.getCameraPosition().altitude).toBeCloseTo(expectedAltitude, 10); + expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10); expect(transform.zoom).toBe(14); // expect new zoom because of elevation change terrain.getElevationForLngLatZoom = () => 400; transform.recalculateZoom(terrain as any); expect(transform.elevation).toBe(400); - expect(transform._center.lng).toBeCloseTo(10, 10); - expect(transform._center.lat).toBeCloseTo(50, 10); - expect(transform.getCameraPosition().altitude).toBeCloseTo(expectedAltitude, 10); + expect(transform.center.lng).toBeCloseTo(10, 10); + expect(transform.center.lat).toBeCloseTo(50, 10); + expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10); expect(transform.zoom).toBeCloseTo(14.1845318986, 10); // expect new zoom because of elevation change to point below sea level terrain.getElevationForLngLatZoom = () => -200; transform.recalculateZoom(terrain as any); expect(transform.elevation).toBe(-200); - expect(transform.getCameraPosition().altitude).toBeCloseTo(expectedAltitude, 10); + expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10); expect(transform.zoom).toBeCloseTo(13.6895075574, 10); }); test('pointCoordinate with terrain when returning null should fall back to 2D', () => { - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(500, 500); const terrain = { pointCoordinate: () => null } as any as Terrain; - const coordinate = transform.pointCoordinate(new Point(0, 0), terrain); + const coordinate = transform.screenPointToMercatorCoordinate(new Point(0, 0), terrain); expect(coordinate).toBeDefined(); }); - test('horizon', () => { - const transform = new Transform(0, 22, 0, 85, true); - transform.resize(500, 500); - transform.pitch = 75; - const horizon = transform.getHorizon(); - - expect(horizon).toBeCloseTo(170.8176101748407, 10); - }); - test('getBounds with horizon', () => { - const transform = new Transform(0, 22, 0, 85, true); + const transform = new MercatorTransform(0, 22, 0, 85, true); transform.resize(500, 500); - transform.pitch = 60; - expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.pointLocation(new Point(0, 0)).toArray()); + transform.setPitch(60); + expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.screenPointToLocation(new Point(0, 0)).toArray()); - transform.pitch = 75; - const top = Math.max(0, transform.height / 2 - transform.getHorizon()); + transform.setPitch(75); + const top = Math.max(0, transform.height / 2 - getMercatorHorizon(transform)); expect(top).toBeCloseTo(79.1823898251593, 10); - expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.pointLocation(new Point(0, top)).toArray()); + expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.screenPointToLocation(new Point(0, top)).toArray()); }); test('lngLatToCameraDepth', () => { - const transform = new Transform(0, 22, 0, 85, true); + const transform = new MercatorTransform(0, 22, 0, 85, true); transform.resize(500, 500); - transform.center = new LngLat(10.0, 50.0); + transform.setCenter(new LngLat(10.0, 50.0)); expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9997324396231673); - transform.pitch = 60; + transform.setPitch(60); expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9865782165762236); + }); + test('projectTileCoordinates', () => { + const precisionDigits = 10; + const transform = new MercatorTransform(0, 22, 0, 85, true); + transform.resize(500, 500); + transform.setCenter(new LngLat(10.0, 50.0)); + let projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0); + expect(projection.point.x).toBeCloseTo(0.0711111094156901, precisionDigits); + expect(projection.point.y).toBeCloseTo(0.872, precisionDigits); + expect(projection.signedDistanceFromCamera).toBeCloseTo(750, precisionDigits); + expect(projection.isOccluded).toBe(false); + transform.setBearing(12); + transform.setPitch(10); + projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0); + expect(projection.point.x).toBeCloseTo(-0.10639783373236278, precisionDigits); + expect(projection.point.y).toBeCloseTo(0.8136785294062687, precisionDigits); + expect(projection.signedDistanceFromCamera).toBeCloseTo(787.6698880195618, precisionDigits); + expect(projection.isOccluded).toBe(false); }); }); diff --git a/src/geo/projection/mercator_transform.ts b/src/geo/projection/mercator_transform.ts new file mode 100644 index 0000000000..d6a6cf641d --- /dev/null +++ b/src/geo/projection/mercator_transform.ts @@ -0,0 +1,786 @@ +import {LngLat, LngLatLike} from '../lng_lat'; +import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate'; +import Point from '@mapbox/point-geometry'; +import {wrap, clamp, createIdentityMat4f64, createMat4f64} from '../../util/util'; +import {mat2, mat4, vec3, vec4} from 'gl-matrix'; +import {UnwrappedTileID, OverscaledTileID, CanonicalTileID, calculateTileKey} from '../../source/tile_id'; +import {Terrain} from '../../render/terrain'; +import {interpolates} from '@maplibre/maplibre-gl-style-spec'; +import {PointProjection, xyTransformMat4} from '../../symbol/projection'; +import {LngLatBounds} from '../lng_lat_bounds'; +import {CoveringTilesOptions, CoveringZoomOptions, IReadonlyTransform, ITransform, TransformUpdateResult} from '../transform_interface'; +import {PaddingOptions} from '../edge_insets'; +import {mercatorCoordinateToLocation, getBasicProjectionData, getMercatorHorizon, locationToMercatorCoordinate, projectToWorldCoordinates, unprojectFromWorldCoordinates, calculateTileMatrix} from './mercator_utils'; +import {EXTENT} from '../../data/extent'; +import type {ProjectionData} from './projection_data'; +import {scaleZoom, TransformHelper, zoomScale} from '../transform_helper'; +import {mercatorCoveringTiles} from './mercator_covering_tiles'; + +export class MercatorTransform implements ITransform { + private _helper: TransformHelper; + + // + // Implementation of transform getters and setters + // + + get pixelsToClipSpaceMatrix(): mat4 { + return this._helper.pixelsToClipSpaceMatrix; + } + get clipSpaceToPixelsMatrix(): mat4 { + return this._helper.clipSpaceToPixelsMatrix; + } + get pixelsToGLUnits(): [number, number] { + return this._helper.pixelsToGLUnits; + } + get centerOffset(): Point { + return this._helper.centerOffset; + } + get size(): Point { + return this._helper.size; + } + get rotationMatrix(): mat2 { + return this._helper.rotationMatrix; + } + get centerPoint(): Point { + return this._helper.centerPoint; + } + get pixelsPerMeter(): number { + return this._helper.pixelsPerMeter; + } + setMinZoom(zoom: number): void { + this._helper.setMinZoom(zoom); + } + setMaxZoom(zoom: number): void { + this._helper.setMaxZoom(zoom); + } + setMinPitch(pitch: number): void { + this._helper.setMinPitch(pitch); + } + setMaxPitch(pitch: number): void { + this._helper.setMaxPitch(pitch); + } + setRenderWorldCopies(renderWorldCopies: boolean): void { + this._helper.setRenderWorldCopies(renderWorldCopies); + } + setBearing(bearing: number): void { + this._helper.setBearing(bearing); + } + setPitch(pitch: number): void { + this._helper.setPitch(pitch); + } + setFov(fov: number): void { + this._helper.setFov(fov); + } + setZoom(zoom: number): void { + this._helper.setZoom(zoom); + } + setCenter(center: LngLat): void { + this._helper.setCenter(center); + } + setElevation(elevation: number): void { + this._helper.setElevation(elevation); + } + setMinElevationForCurrentTile(elevation: number): void { + this._helper.setMinElevationForCurrentTile(elevation); + } + setPadding(padding: PaddingOptions): void { + this._helper.setPadding(padding); + } + interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void { + return this._helper.interpolatePadding(start, target, t); + } + isPaddingEqual(padding: PaddingOptions): boolean { + return this._helper.isPaddingEqual(padding); + } + coveringZoomLevel(options: CoveringZoomOptions): number { + return this._helper.coveringZoomLevel(options); + } + resize(width: number, height: number): void { + this._helper.resize(width, height); + } + getMaxBounds(): LngLatBounds { + return this._helper.getMaxBounds(); + } + setMaxBounds(bounds?: LngLatBounds): void { + this._helper.setMaxBounds(bounds); + } + getCameraQueryGeometry(queryGeometry: Point[]): Point[] { + return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry); + } + + get tileSize(): number { + return this._helper.tileSize; + } + get tileZoom(): number { + return this._helper.tileZoom; + } + get scale(): number { + return this._helper.scale; + } + get worldSize(): number { + return this._helper.worldSize; + } + get width(): number { + return this._helper.width; + } + get height(): number { + return this._helper.height; + } + get angle(): number { + return this._helper.angle; + } + get lngRange(): [number, number] { + return this._helper.lngRange; + } + get latRange(): [number, number] { + return this._helper.latRange; + } + get minZoom(): number { + return this._helper.minZoom; + } + get maxZoom(): number { + return this._helper.maxZoom; + } + get zoom(): number { + return this._helper.zoom; + } + get center(): LngLat { + return this._helper.center; + } + get minPitch(): number { + return this._helper.minPitch; + } + get maxPitch(): number { + return this._helper.maxPitch; + } + get pitch(): number { + return this._helper.pitch; + } + get bearing(): number { + return this._helper.bearing; + } + get fov(): number { + return this._helper.fov; + } + get elevation(): number { + return this._helper.elevation; + } + get minElevationForCurrentTile(): number { + return this._helper.minElevationForCurrentTile; + } + get padding(): PaddingOptions { + return this._helper.padding; + } + get unmodified(): boolean { + return this._helper.unmodified; + } + get renderWorldCopies(): boolean { + return this._helper.renderWorldCopies; + } + + // + // Implementation of mercator transform + // + + private _cameraToCenterDistance: number; + private _cameraPosition: vec3; + + private _mercatorMatrix: mat4; + private _projectionMatrix: mat4; + private _viewProjMatrix: mat4; + private _invViewProjMatrix: mat4; + private _invProjMatrix: mat4; + private _alignedProjMatrix: mat4; + private _pixelMatrix: mat4; + private _pixelMatrix3D: mat4; + private _pixelMatrixInverse: mat4; + private _fogMatrix: mat4; + + private _posMatrixCache: {[_: string]: mat4}; + private _alignedPosMatrixCache: {[_: string]: mat4}; + private _fogMatrixCache: {[_: string]: mat4}; + + private _nearZ; + private _farZ; + + constructor(minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) { + this._helper = new TransformHelper({ + calcMatrices: () => { this._calcMatrices(); }, + getConstrained: (center, zoom) => { return this.getConstrained(center, zoom); } + }, minZoom, maxZoom, minPitch, maxPitch, renderWorldCopies); + this._clearMatrixCaches(); + } + + public clone(): ITransform { + const clone = new MercatorTransform(); + clone.apply(this); + return clone; + } + + public apply(that: IReadonlyTransform, constrain?: boolean): void { + this._helper.apply(that, constrain); + } + + public get cameraToCenterDistance(): number { return this._cameraToCenterDistance; } + public get cameraPosition(): vec3 { return this._cameraPosition; } + public get projectionMatrix(): mat4 { return this._projectionMatrix; } + public get modelViewProjectionMatrix(): mat4 { return this._viewProjMatrix; } + public get inverseProjectionMatrix(): mat4 { return this._invProjMatrix; } + public get nearZ(): number { return this._nearZ; } + public get farZ(): number { return this._farZ; } + + public get mercatorMatrix(): mat4 { return this._mercatorMatrix; } // Not part of ITransform interface + + getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): Array { + const result = [new UnwrappedTileID(0, tileID)]; + if (this._helper._renderWorldCopies) { + const utl = this.screenPointToMercatorCoordinate(new Point(0, 0)); + const utr = this.screenPointToMercatorCoordinate(new Point(this._helper._width, 0)); + const ubl = this.screenPointToMercatorCoordinate(new Point(this._helper._width, this._helper._height)); + const ubr = this.screenPointToMercatorCoordinate(new Point(0, this._helper._height)); + const w0 = Math.floor(Math.min(utl.x, utr.x, ubl.x, ubr.x)); + const w1 = Math.floor(Math.max(utl.x, utr.x, ubl.x, ubr.x)); + + // Add an extra copy of the world on each side to properly render ImageSources and CanvasSources. + // Both sources draw outside the tile boundaries of the tile that "contains them" so we need + // to add extra copies on both sides in case offscreen tiles need to draw into on-screen ones. + const extraWorldCopy = 1; + + for (let w = w0 - extraWorldCopy; w <= w1 + extraWorldCopy; w++) { + if (w === 0) continue; + result.push(new UnwrappedTileID(w, tileID)); + } + } + return result; + } + + coveringTiles(options: CoveringTilesOptions): Array { + return mercatorCoveringTiles(this, options, this._invViewProjMatrix); + } + + recalculateZoom(terrain: Terrain): void { + const origElevation = this.elevation; + const origAltitude = Math.cos(this._helper._pitch) * this._cameraToCenterDistance / this._helper._pixelPerMeter; + + // find position the camera is looking on + const center = this.screenPointToLocation(this.centerPoint, terrain); + const elevation = terrain.getElevationForLngLatZoom(center, this._helper._tileZoom); + const deltaElevation = this.elevation - elevation; + if (!deltaElevation) return; + + // The camera's altitude off the ground + the ground's elevation = a constant: + // this means the camera stays at the same total height. + const requiredAltitude = origAltitude + origElevation - elevation; + // Since altitude = Math.cos(this._pitch) * this.cameraToCenterDistance / pixelPerMeter: + const requiredPixelPerMeter = Math.cos(this._helper._pitch) * this._cameraToCenterDistance / requiredAltitude; + // Since pixelPerMeter = mercatorZfromAltitude(1, center.lat) * worldSize: + const requiredWorldSize = requiredPixelPerMeter / mercatorZfromAltitude(1, center.lat); + // Since worldSize = this.tileSize * scale: + const requiredScale = requiredWorldSize / this.tileSize; + const zoom = scaleZoom(requiredScale); + + // update matrices + this._helper._elevation = elevation; + this._helper._center = center; + this.setZoom(zoom); + } + + setLocationAtPoint(lnglat: LngLat, point: Point) { + const a = this.screenPointToMercatorCoordinate(point); + const b = this.screenPointToMercatorCoordinate(this.centerPoint); + const loc = locationToMercatorCoordinate(lnglat); + const newCenter = new MercatorCoordinate( + loc.x - (a.x - b.x), + loc.y - (a.y - b.y)); + this.setCenter(mercatorCoordinateToLocation(newCenter)); + if (this._helper._renderWorldCopies) { + this.setCenter(this.center.wrap()); + } + } + + locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point { + return terrain ? + this.coordinatePoint(locationToMercatorCoordinate(lnglat), terrain.getElevationForLngLatZoom(lnglat, this._helper._tileZoom), this._pixelMatrix3D) : + this.coordinatePoint(locationToMercatorCoordinate(lnglat)); + } + + screenPointToLocation(p: Point, terrain?: Terrain): LngLat { + return mercatorCoordinateToLocation(this.screenPointToMercatorCoordinate(p, terrain)); + } + + screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate { + // get point-coordinate from terrain coordinates framebuffer + if (terrain) { + const coordinate = terrain.pointCoordinate(p); + if (coordinate != null) { + return coordinate; + } + } + + // calculate point-coordinate on flat earth + const targetZ = 0; + // since we don't know the correct projected z value for the point, + // unproject two points to get a line and then find the point on that + // line with z=0 + + const coord0 = [p.x, p.y, 0, 1] as vec4; + const coord1 = [p.x, p.y, 1, 1] as vec4; + + vec4.transformMat4(coord0, coord0, this._pixelMatrixInverse); + vec4.transformMat4(coord1, coord1, this._pixelMatrixInverse); + + const w0 = coord0[3]; + const w1 = coord1[3]; + const x0 = coord0[0] / w0; + const x1 = coord1[0] / w1; + const y0 = coord0[1] / w0; + const y1 = coord1[1] / w1; + const z0 = coord0[2] / w0; + const z1 = coord1[2] / w1; + + const t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0); + + return new MercatorCoordinate( + interpolates.number(x0, x1, t) / this.worldSize, + interpolates.number(y0, y1, t) / this.worldSize); + } + + /** + * Given a coordinate, return the screen point that corresponds to it + * @param coord - the coordinates + * @param elevation - the elevation + * @param pixelMatrix - the pixel matrix + * @returns screen point + */ + coordinatePoint(coord: MercatorCoordinate, elevation: number = 0, pixelMatrix: mat4 = this._pixelMatrix): Point { + const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4; + vec4.transformMat4(p, p, pixelMatrix); + return new Point(p[0] / p[3], p[1] / p[3]); + } + + getBounds(): LngLatBounds { + const top = Math.max(0, this._helper._height / 2 - getMercatorHorizon(this)); + return new LngLatBounds() + .extend(this.screenPointToLocation(new Point(0, top))) + .extend(this.screenPointToLocation(new Point(this._helper._width, top))) + .extend(this.screenPointToLocation(new Point(this._helper._width, this._helper._height))) + .extend(this.screenPointToLocation(new Point(0, this._helper._height))); + } + + isPointOnMapSurface(p: Point, terrain?: Terrain): boolean { + if (terrain) { + const coordinate = terrain.pointCoordinate(p); + return coordinate != null; + } + return (p.y > this.height / 2 - getMercatorHorizon(this)); + } + + /** + * Calculate the posMatrix that, given a tile coordinate, would be used to display the tile on a map. + * This function is specific to the mercator projection. + * @param tileID - the tile ID + * @param aligned - whether to use a pixel-aligned matrix variant, intended for rendering raster tiles + */ + calculatePosMatrix(tileID: UnwrappedTileID | OverscaledTileID, aligned: boolean = false): mat4 { + const posMatrixKey = tileID.key ?? calculateTileKey(tileID.wrap, tileID.canonical.z, tileID.canonical.z, tileID.canonical.x, tileID.canonical.y); + const cache = aligned ? this._alignedPosMatrixCache : this._posMatrixCache; + if (cache[posMatrixKey]) { + return cache[posMatrixKey]; + } + + const tileMatrix = calculateTileMatrix(tileID, this.worldSize); + mat4.multiply(tileMatrix, aligned ? this._alignedProjMatrix : this._viewProjMatrix, tileMatrix); + + cache[posMatrixKey] = new Float32Array(tileMatrix); + return cache[posMatrixKey]; + } + + calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4 { + const posMatrixKey = unwrappedTileID.key; + const cache = this._fogMatrixCache; + if (cache[posMatrixKey]) { + return cache[posMatrixKey]; + } + + const fogMatrix = calculateTileMatrix(unwrappedTileID, this.worldSize); + mat4.multiply(fogMatrix, this._fogMatrix, fogMatrix); + + cache[posMatrixKey] = new Float32Array(fogMatrix); + return cache[posMatrixKey]; + } + + /** + * This mercator implementation returns center lngLat and zoom to ensure that: + * + * 1) everything beyond the bounds is excluded + * 2) a given lngLat is as near the center as possible + * + * Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian. + */ + getConstrained(lngLat: LngLat, zoom: number): {center: LngLat; zoom: number} { + zoom = clamp(+zoom, this.minZoom, this.maxZoom); + const result = { + center: new LngLat(lngLat.lng, lngLat.lat), + zoom + }; + + let lngRange = this._helper._lngRange; + + if (!this._helper._renderWorldCopies && lngRange === null) { + const almost180 = 180 - 1e-10; + lngRange = [-almost180, almost180]; + } + + const worldSize = this.tileSize * zoomScale(result.zoom); // A world size for the requested zoom level, not the current world size + let minY = 0; + let maxY = worldSize; + let minX = 0; + let maxX = worldSize; + let scaleY = 0; + let scaleX = 0; + const {x: screenWidth, y: screenHeight} = this.size; + + if (this._helper._latRange) { + const latRange = this._helper._latRange; + minY = mercatorYfromLat(latRange[1]) * worldSize; + maxY = mercatorYfromLat(latRange[0]) * worldSize; + const shouldZoomIn = maxY - minY < screenHeight; + if (shouldZoomIn) scaleY = screenHeight / (maxY - minY); + } + + const lngUnlimited = !this.lngRange || Math.abs(this.lngRange[0] - this.lngRange[1]) > 360; + if (lngRange && !lngUnlimited) { + minX = wrap( + mercatorXfromLng(lngRange[0]) * worldSize, + 0, + worldSize + ); + maxX = wrap( + mercatorXfromLng(lngRange[1]) * worldSize, + 0, + worldSize + ); + + if (maxX < minX) maxX += worldSize; + + const shouldZoomIn = maxX - minX < screenWidth; + if (shouldZoomIn) scaleX = screenWidth / (maxX - minX); + } + + const {x: originalX, y: originalY} = projectToWorldCoordinates(worldSize, lngLat); + let modifiedX, modifiedY; + + const scale = Math.max(scaleX || 0, scaleY || 0); + + if (scale) { + // zoom in to exclude all beyond the given lng/lat ranges + const newPoint = new Point( + scaleX ? (maxX + minX) / 2 : originalX, + scaleY ? (maxY + minY) / 2 : originalY); + result.center = unprojectFromWorldCoordinates(worldSize, newPoint).wrap(); + result.zoom += scaleZoom(scale); + return result; + } + + if (this._helper._latRange) { + const h2 = screenHeight / 2; + if (originalY - h2 < minY) modifiedY = minY + h2; + if (originalY + h2 > maxY) modifiedY = maxY - h2; + } + + if (lngRange && !lngUnlimited) { + const centerX = (minX + maxX) / 2; + let wrappedX = originalX; + if (this._helper._renderWorldCopies) { + wrappedX = wrap(originalX, centerX - worldSize / 2, centerX + worldSize / 2); + } + const w2 = screenWidth / 2; + + if (wrappedX - w2 < minX) modifiedX = minX + w2; + if (wrappedX + w2 > maxX) modifiedX = maxX - w2; + } + + // pan the map if the screen goes off the range + if (modifiedX !== undefined || modifiedY !== undefined) { + const newPoint = new Point(modifiedX ?? originalX, modifiedY ?? originalY); + result.center = unprojectFromWorldCoordinates(worldSize, newPoint).wrap(); + } + + return result; + } + + _calcMatrices(): void { + if (!this._helper._height) return; + + const halfFov = this._helper._fov / 2; + const offset = this.centerOffset; + const point = projectToWorldCoordinates(this.worldSize, this.center); + const x = point.x, y = point.y; + this._cameraToCenterDistance = 0.5 / Math.tan(halfFov) * this._helper._height; + this._helper._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; + + // Calculate the camera to sea-level distance in pixel in respect of terrain + const cameraToSeaLevelDistance = this._cameraToCenterDistance + this._helper._elevation * this._helper._pixelPerMeter / Math.cos(this._helper._pitch); + // In case of negative minimum elevation (e.g. the dead see, under the sea maps) use a lower plane for calculation + const minElevation = Math.min(this.elevation, this.minElevationForCurrentTile); + const cameraToLowestPointDistance = cameraToSeaLevelDistance - minElevation * this._helper._pixelPerMeter / Math.cos(this._helper._pitch); + const lowestPlane = minElevation < 0 ? cameraToLowestPointDistance : cameraToSeaLevelDistance; + + // Find the distance from the center point [width/2 + offset.x, height/2 + offset.y] to the + // center top point [width/2 + offset.x, 0] in Z units, using the law of sines. + // 1 Z unit is equivalent to 1 horizontal px at the center of the map + // (the distance between[width/2, height/2] and [width/2 + 1, height/2]) + const groundAngle = Math.PI / 2 + this._helper._pitch; + const fovAboveCenter = this._helper._fov * (0.5 + offset.y / this._helper._height); + const topHalfSurfaceDistance = Math.sin(fovAboveCenter) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01)); + + // Find the distance from the center point to the horizon + const horizon = getMercatorHorizon(this); + const horizonAngle = Math.atan(horizon / this._cameraToCenterDistance); + const fovCenterToHorizon = 2 * horizonAngle * (0.5 + offset.y / (horizon * 2)); + const topHalfSurfaceDistanceHorizon = Math.sin(fovCenterToHorizon) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovCenterToHorizon, 0.01, Math.PI - 0.01)); + + // Calculate z distance of the farthest fragment that should be rendered. + // Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance` + const topHalfMinDistance = Math.min(topHalfSurfaceDistance, topHalfSurfaceDistanceHorizon); + this._farZ = (Math.cos(Math.PI / 2 - this._helper._pitch) * topHalfMinDistance + lowestPlane) * 1.01; + + // The larger the value of nearZ is + // - the more depth precision is available for features (good) + // - clipping starts appearing sooner when the camera is close to 3d features (bad) + // + // Other values work for mapbox-gl-js but deck.gl was encountering precision issues + // when rendering custom layers. This value was experimentally chosen and + // seems to solve z-fighting issues in deck.gl while not clipping buildings too close to the camera. + this._nearZ = this._helper._height / 50; + + // matrix for conversion from location to clip space(-1 .. 1) + let m: mat4; + m = new Float64Array(16) as any; + mat4.perspective(m, this._helper._fov, this._helper._width / this._helper._height, this._nearZ, this._farZ); + this._invProjMatrix = new Float64Array(16) as any as mat4; + mat4.invert(this._invProjMatrix, m); + + // Apply center of perspective offset + m[8] = -offset.x * 2 / this._helper._width; + m[9] = offset.y * 2 / this._helper._height; + this._projectionMatrix = mat4.clone(m); + + mat4.scale(m, m, [1, -1, 1]); + mat4.translate(m, m, [0, 0, -this._cameraToCenterDistance]); + mat4.rotateX(m, m, this._helper._pitch); + mat4.rotateZ(m, m, this._helper._angle); + mat4.translate(m, m, [-x, -y, 0]); + + // The mercatorMatrix can be used to transform points from mercator coordinates + // ([0, 0] nw, [1, 1] se) to clip space. + this._mercatorMatrix = mat4.scale([] as any, m, [this.worldSize, this.worldSize, this.worldSize]); + + // scale vertically to meters per pixel (inverse of ground resolution): + mat4.scale(m, m, [1, 1, this._helper._pixelPerMeter]); + + // matrix for conversion from world space to screen coordinates in 2D + this._pixelMatrix = mat4.multiply(new Float64Array(16) as any, this.clipSpaceToPixelsMatrix, m); + + // matrix for conversion from world space to clip space (-1 .. 1) + mat4.translate(m, m, [0, 0, -this.elevation]); // elevate camera over terrain + this._viewProjMatrix = m; + this._invViewProjMatrix = mat4.invert([] as any, m); + + const cameraPos: vec4 = [0, 0, -1, 1]; + vec4.transformMat4(cameraPos, cameraPos, this._invViewProjMatrix); + this._cameraPosition = [ + cameraPos[0] / cameraPos[3], + cameraPos[1] / cameraPos[3], + cameraPos[2] / cameraPos[3] + ]; + + // create a fog matrix, same es proj-matrix but with near clipping-plane in mapcenter + // needed to calculate a correct z-value for fog calculation, because projMatrix z value is not + this._fogMatrix = new Float64Array(16) as any; + mat4.perspective(this._fogMatrix, this._helper._fov, this.width / this.height, cameraToSeaLevelDistance, this._farZ); + this._fogMatrix[8] = -offset.x * 2 / this.width; + this._fogMatrix[9] = offset.y * 2 / this.height; + mat4.scale(this._fogMatrix, this._fogMatrix, [1, -1, 1]); + mat4.translate(this._fogMatrix, this._fogMatrix, [0, 0, -this.cameraToCenterDistance]); + mat4.rotateX(this._fogMatrix, this._fogMatrix, this._helper._pitch); + mat4.rotateZ(this._fogMatrix, this._fogMatrix, this.angle); + mat4.translate(this._fogMatrix, this._fogMatrix, [-x, -y, 0]); + mat4.scale(this._fogMatrix, this._fogMatrix, [1, 1, this._helper._pixelPerMeter]); + mat4.translate(this._fogMatrix, this._fogMatrix, [0, 0, -this.elevation]); // elevate camera over terrain + + // matrix for conversion from world space to screen coordinates in 3D + this._pixelMatrix3D = mat4.multiply(new Float64Array(16) as any, this.clipSpaceToPixelsMatrix, m); + + // Make a second projection matrix that is aligned to a pixel grid for rendering raster tiles. + // We're rounding the (floating point) x/y values to achieve to avoid rendering raster images to fractional + // coordinates. Additionally, we adjust by half a pixel in either direction in case that viewport dimension + // is an odd integer to preserve rendering to the pixel grid. We're rotating this shift based on the angle + // of the transformation so that 0°, 90°, 180°, and 270° rasters are crisp, and adjust the shift so that + // it is always <= 0.5 pixels. + const xShift = (this._helper._width % 2) / 2, yShift = (this._helper._height % 2) / 2, + angleCos = Math.cos(this._helper._angle), angleSin = Math.sin(this._helper._angle), + dx = x - Math.round(x) + angleCos * xShift + angleSin * yShift, + dy = y - Math.round(y) + angleCos * yShift + angleSin * xShift; + const alignedM = new Float64Array(m) as any as mat4; + mat4.translate(alignedM, alignedM, [dx > 0.5 ? dx - 1 : dx, dy > 0.5 ? dy - 1 : dy, 0]); + this._alignedProjMatrix = alignedM; + + // inverse matrix for conversion from screen coordinates to location + m = mat4.invert(new Float64Array(16) as any, this._pixelMatrix); + if (!m) throw new Error('failed to invert matrix'); + this._pixelMatrixInverse = m; + + this._clearMatrixCaches(); + } + + private _clearMatrixCaches(): void { + this._posMatrixCache = {}; + this._alignedPosMatrixCache = {}; + this._fogMatrixCache = {}; + } + + maxPitchScaleFactor(): number { + // calcMatrices hasn't run yet + if (!this._pixelMatrixInverse) return 1; + + const coord = this.screenPointToMercatorCoordinate(new Point(0, 0)); + const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1] as vec4; + const topPoint = vec4.transformMat4(p, p, this._pixelMatrix); + return topPoint[3] / this._cameraToCenterDistance; + } + + getCameraPoint(): Point { + const pitch = this._helper._pitch; + const yOffset = Math.tan(pitch) * (this._cameraToCenterDistance || 1); + return this.centerPoint.add(new Point(0, yOffset)); + } + + getCameraAltitude(): number { + const altitude = Math.cos(this._helper._pitch) * this._cameraToCenterDistance / this._helper._pixelPerMeter; + return altitude + this.elevation; + } + + lngLatToCameraDepth(lngLat: LngLat, elevation: number) { + const coord = locationToMercatorCoordinate(lngLat); + const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4; + vec4.transformMat4(p, p, this._viewProjMatrix); + return (p[2] / p[3]); + } + + isRenderingDirty(): boolean { + return false; + } + + getProjectionData(overscaledTileID: OverscaledTileID, aligned?: boolean, ignoreTerrainMatrix?: boolean): ProjectionData { + const matrix = overscaledTileID ? this.calculatePosMatrix(overscaledTileID, aligned) : null; + return getBasicProjectionData(overscaledTileID, matrix, ignoreTerrainMatrix); + } + + isLocationOccluded(_: LngLat): boolean { + return false; + } + + tileCoordinatesOccluded(_inTileX: number, _inTileY: number, _canonicalTileID: {x: number; y: number; z: number}): boolean { + return false; + } + + getPixelScale(): number { + return 1.0; + } + + getCircleRadiusCorrection(): number { + return 1.0; + } + + getPitchedTextCorrection(_textAnchorX: number, _textAnchorY: number, _tileID: UnwrappedTileID): number { + return 1.0; + } + + newFrameUpdate(): TransformUpdateResult { + return {}; + } + + transformLightDirection(dir: vec3): vec3 { + return vec3.clone(dir); + } + + getRayDirectionFromPixel(_p: Point): vec3 { + throw new Error('Not implemented.'); // No need for this in mercator transform + } + + projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection { + const matrix = this.calculatePosMatrix(unwrappedTileID); + let pos; + if (getElevation) { // slow because of handle z-index + pos = [x, y, getElevation(x, y), 1] as vec4; + vec4.transformMat4(pos, pos, matrix); + } else { // fast because of ignore z-index + pos = [x, y, 0, 1] as vec4; + xyTransformMat4(pos, pos, matrix); + } + const w = pos[3]; + return { + point: new Point(pos[0] / w, pos[1] / w), + signedDistanceFromCamera: w, + isOccluded: false + }; + } + + precacheTiles(coords: Array): void { + for (const coord of coords) { + // Return value is thrown away, but this function will still + // place the pos matrix into the transform's internal cache. + this.calculatePosMatrix(coord); + } + } + + getMatrixForModel(location: LngLatLike, altitude?: number): mat4 { + const modelAsMercatorCoordinate = MercatorCoordinate.fromLngLat( + location, + altitude + ); + const scale = modelAsMercatorCoordinate.meterInMercatorCoordinateUnits(); + + const m = createIdentityMat4f64(); + mat4.translate(m, m, [modelAsMercatorCoordinate.x, modelAsMercatorCoordinate.y, modelAsMercatorCoordinate.z]); + mat4.rotateZ(m, m, Math.PI); + mat4.rotateX(m, m, Math.PI / 2); + mat4.scale(m, m, [-scale, scale, scale]); + return m; + } + + getProjectionDataForCustomLayer(): ProjectionData { + const tileID = new OverscaledTileID(0, 0, 0, 0, 0); + const projectionData = this.getProjectionData(tileID, false, true); + + const tileMatrix = calculateTileMatrix(tileID, this.worldSize); + mat4.multiply(tileMatrix, this._viewProjMatrix, tileMatrix); + + projectionData.tileMercatorCoords = [0, 0, 1, 1]; + + // Even though we requested projection data for the mercator base tile which covers the entire mercator range, + // the shader projection machinery still expects inputs to be in tile units range [0..EXTENT]. + // Since custom layers are expected to supply mercator coordinates [0..1], we need to rescale + // both matrices by EXTENT. We also need to rescale Z. + + const scale: vec3 = [EXTENT, EXTENT, this.worldSize / this._helper.pixelsPerMeter]; + const translate: vec3 = [0, 0, this.elevation]; + + const fallbackMatrixScaled = createMat4f64(); + mat4.translate(fallbackMatrixScaled, tileMatrix, translate); + mat4.scale(fallbackMatrixScaled, fallbackMatrixScaled, scale); + + const projectionMatrixScaled = createMat4f64(); + mat4.translate(projectionMatrixScaled, tileMatrix, translate); + mat4.scale(projectionMatrixScaled, projectionMatrixScaled, scale); + + projectionData.fallbackMatrix = fallbackMatrixScaled; + projectionData.mainMatrix = projectionMatrixScaled; + return projectionData; + } + + getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 { + return this.calculatePosMatrix(tileID); + } +} diff --git a/src/geo/projection/mercator_utils.test.ts b/src/geo/projection/mercator_utils.test.ts new file mode 100644 index 0000000000..b5853d167a --- /dev/null +++ b/src/geo/projection/mercator_utils.test.ts @@ -0,0 +1,116 @@ +import Point from '@mapbox/point-geometry'; +import {LngLat} from '../lng_lat'; +import {getBasicProjectionData, getMercatorHorizon, locationToMercatorCoordinate, projectToWorldCoordinates, tileCoordinatesToLocation, tileCoordinatesToMercatorCoordinates} from './mercator_utils'; +import {MercatorTransform} from './mercator_transform'; +import {MAX_VALID_LATITUDE} from '../transform_helper'; +import {mat4} from 'gl-matrix'; +import {CanonicalTileID, OverscaledTileID} from '../../source/tile_id'; +import {EXTENT} from '../../data/extent'; +import {expectToBeCloseToArray} from '../../util/test/util'; +import type {ProjectionData} from './projection_data'; + +describe('mercator utils', () => { + test('projectToWorldCoordinates basic', () => { + const transform = new MercatorTransform(0, 22, 0, 60, true); + transform.setZoom(10); + expect(projectToWorldCoordinates(transform.worldSize, transform.center)).toEqual(new Point(262144, 262144)); + }); + + test('projectToWorldCoordinates clamps latitude', () => { + const transform = new MercatorTransform(0, 22, 0, 60, true); + + expect(projectToWorldCoordinates(transform.worldSize, new LngLat(0, -90))).toEqual(projectToWorldCoordinates(transform.worldSize, new LngLat(0, -MAX_VALID_LATITUDE))); + expect(projectToWorldCoordinates(transform.worldSize, new LngLat(0, 90))).toEqual(projectToWorldCoordinates(transform.worldSize, new LngLat(0, MAX_VALID_LATITUDE))); + }); + + test('locationCoordinate', () => { + expect(locationToMercatorCoordinate(new LngLat(0, 0))).toEqual({x: 0.5, y: 0.5, z: 0}); + }); + + test('getMercatorHorizon', () => { + const transform = new MercatorTransform(0, 22, 0, 85, true); + transform.resize(500, 500); + transform.setPitch(75); + const horizon = getMercatorHorizon(transform); + + expect(horizon).toBeCloseTo(170.8176101748407, 10); + }); + + describe('getBasicProjectionData', () => { + test('posMatrix is set', () => { + const mat = mat4.create(); + mat[0] = 1234; + const projectionData = getBasicProjectionData(new OverscaledTileID(0, 0, 0, 0, 0), mat); + expect(projectionData.fallbackMatrix).toEqual(mat); + }); + + test('mercator tile extents are set', () => { + let projectionData: ProjectionData; + + projectionData = getBasicProjectionData(new OverscaledTileID(0, 0, 0, 0, 0)); + expectToBeCloseToArray(projectionData.tileMercatorCoords, [0, 0, 1 / EXTENT, 1 / EXTENT]); + + projectionData = getBasicProjectionData(new OverscaledTileID(1, 0, 1, 0, 0)); + expectToBeCloseToArray(projectionData.tileMercatorCoords, [0, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + + projectionData = getBasicProjectionData(new OverscaledTileID(1, 0, 1, 1, 0)); + expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]); + }); + }); + + describe('tileCoordinatesToMercatorCoordinates', () => { + const precisionDigits = 10; + + test('Test 0,0', () => { + const result = tileCoordinatesToMercatorCoordinates(0, 0, new CanonicalTileID(0, 0, 0)); + expect(result.x).toBe(0); + expect(result.y).toBe(0); + }); + + test('Test tile center', () => { + const result = tileCoordinatesToMercatorCoordinates(EXTENT / 2, EXTENT / 2, new CanonicalTileID(0, 0, 0)); + expect(result.x).toBeCloseTo(0.5, precisionDigits); + expect(result.y).toBeCloseTo(0.5, precisionDigits); + }); + + test('Test higher zoom 0,0', () => { + const result = tileCoordinatesToMercatorCoordinates(0, 0, new CanonicalTileID(3, 0, 0)); + expect(result.x).toBe(0); + expect(result.y).toBe(0); + }); + + test('Test higher zoom tile center', () => { + const result = tileCoordinatesToMercatorCoordinates(EXTENT / 2, EXTENT / 2, new CanonicalTileID(3, 0, 0)); + expect(result.x).toBeCloseTo(1 / 16, precisionDigits); + expect(result.y).toBeCloseTo(1 / 16, precisionDigits); + }); + }); + + describe('tileCoordinatesToLocation', () => { + const precisionDigits = 5; + + test('Test 0,0', () => { + const result = tileCoordinatesToLocation(0, 0, new CanonicalTileID(0, 0, 0)); + expect(result.lng).toBeCloseTo(-180, precisionDigits); + expect(result.lat).toBeCloseTo(MAX_VALID_LATITUDE, precisionDigits); + }); + + test('Test tile center', () => { + const result = tileCoordinatesToLocation(EXTENT / 2, EXTENT / 2, new CanonicalTileID(0, 0, 0)); + expect(result.lng).toBeCloseTo(0, precisionDigits); + expect(result.lat).toBeCloseTo(0, precisionDigits); + }); + + test('Test higher zoom 0,0', () => { + const result = tileCoordinatesToLocation(0, 0, new CanonicalTileID(3, 0, 0)); + expect(result.lng).toBeCloseTo(-180, precisionDigits); + expect(result.lat).toBeCloseTo(MAX_VALID_LATITUDE, precisionDigits); + }); + + test('Test higher zoom mercator center', () => { + const result = tileCoordinatesToLocation(EXTENT, EXTENT, new CanonicalTileID(3, 3, 3)); + expect(result.lng).toBeCloseTo(0, precisionDigits); + expect(result.lat).toBeCloseTo(0, precisionDigits); + }); + }); +}); diff --git a/src/geo/projection/mercator_utils.ts b/src/geo/projection/mercator_utils.ts new file mode 100644 index 0000000000..dee0e77feb --- /dev/null +++ b/src/geo/projection/mercator_utils.ts @@ -0,0 +1,132 @@ +import {mat4} from 'gl-matrix'; +import {EXTENT} from '../../data/extent'; +import {OverscaledTileID} from '../../source/tile_id'; +import {clamp} from '../../util/util'; +import {MAX_VALID_LATITUDE, UnwrappedTileIDType, zoomScale} from '../transform_helper'; +import {LngLat} from '../lng_lat'; +import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat} from '../mercator_coordinate'; +import Point from '@mapbox/point-geometry'; +import type {ProjectionData} from './projection_data'; + +/** + * Returns mercator coordinates in range 0..1 for given coordinates inside a specified tile. + * @param inTileX - X coordinate in tile units - range [0..EXTENT]. + * @param inTileY - Y coordinate in tile units - range [0..EXTENT]. + * @param canonicalTileID - Tile canonical ID - mercator X, Y and zoom. + * @returns Mercator coordinates of the specified point in range [0..1]. + */ +export function tileCoordinatesToMercatorCoordinates(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): MercatorCoordinate { + const scale = 1.0 / (1 << canonicalTileID.z); + return new MercatorCoordinate( + inTileX / EXTENT * scale + canonicalTileID.x * scale, + inTileY / EXTENT * scale + canonicalTileID.y * scale + ); +} + +/** + * Returns LngLat for given in-tile coordinates and tile ID. + * @param inTileX - X coordinate in tile units - range [0..EXTENT]. + * @param inTileY - Y coordinate in tile units - range [0..EXTENT]. + * @param canonicalTileID - Tile canonical ID - mercator X, Y and zoom. + */ +export function tileCoordinatesToLocation(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): LngLat { + return tileCoordinatesToMercatorCoordinates(inTileX, inTileY, canonicalTileID).toLngLat(); +} + +/** + * Given a geographical lnglat, return an unrounded + * coordinate that represents it at low zoom level. + * @param lnglat - the location + * @returns The mercator coordinate + */ +export function locationToMercatorCoordinate(lnglat: LngLat): MercatorCoordinate { + return MercatorCoordinate.fromLngLat(lnglat); +} + +/** + * Given a Coordinate, return its geographical position. + * @param coord - mercator coordinates + * @returns lng and lat + */ +export function mercatorCoordinateToLocation(coord: MercatorCoordinate): LngLat { + return coord && coord.toLngLat(); +} + +/** + * Convert from LngLat to world coordinates (Mercator coordinates scaled by world size). + * @param worldSize - Mercator world size computed from zoom level and tile size. + * @param lnglat - The location to convert. + * @returns Point + */ +export function projectToWorldCoordinates(worldSize: number, lnglat: LngLat): Point { + const lat = clamp(lnglat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE); + return new Point( + mercatorXfromLng(lnglat.lng) * worldSize, + mercatorYfromLat(lat) * worldSize); +} + +/** + * Convert from world coordinates (mercator coordinates scaled by world size) to LngLat. + * @param worldSize - Mercator world size computed from zoom level and tile size. + * @param point - World coordinate. + * @returns LngLat + */ +export function unprojectFromWorldCoordinates(worldSize: number, point: Point): LngLat { + return new MercatorCoordinate(point.x / worldSize, point.y / worldSize).toLngLat(); +} + +/** + * Calculate pixel height of the visible horizon in relation to map-center (e.g. height/2), + * multiplied by a static factor to simulate the earth-radius. + * The calculated value is the horizontal line from the camera-height to sea-level. + * @returns Horizon above center in pixels. + */ +export function getMercatorHorizon(transform: {pitch: number; cameraToCenterDistance: number}): number { + return Math.tan(Math.PI / 2 - transform.pitch * Math.PI / 180.0) * transform.cameraToCenterDistance * 0.85; +} + +export function getBasicProjectionData(overscaledTileID: OverscaledTileID, tilePosMatrix?: mat4, ignoreTerrainMatrix?: boolean): ProjectionData { + let tileOffsetSize: [number, number, number, number]; + + if (overscaledTileID) { + const scale = (overscaledTileID.canonical.z >= 0) ? (1 << overscaledTileID.canonical.z) : Math.pow(2.0, overscaledTileID.canonical.z); + tileOffsetSize = [ + overscaledTileID.canonical.x / scale, + overscaledTileID.canonical.y / scale, + 1.0 / scale / EXTENT, + 1.0 / scale / EXTENT + ]; + } else { + tileOffsetSize = [0, 0, 1, 1]; + } + + let mainMatrix: mat4; + if (overscaledTileID && overscaledTileID.terrainRttPosMatrix && !ignoreTerrainMatrix) { + mainMatrix = overscaledTileID.terrainRttPosMatrix; + } else if (tilePosMatrix) { + mainMatrix = tilePosMatrix; + } else { + mainMatrix = mat4.create(); + } + + const data: ProjectionData = { + mainMatrix, // Might be set to a custom matrix by different projections. + tileMercatorCoords: tileOffsetSize, + clippingPlane: [0, 0, 0, 0], + projectionTransition: 0.0, // Range 0..1, where 0 is mercator, 1 is another projection, mostly globe. + fallbackMatrix: mainMatrix, + }; + + return data; +} + +export function calculateTileMatrix(unwrappedTileID: UnwrappedTileIDType, worldSize: number): mat4 { + const canonical = unwrappedTileID.canonical; + const scale = worldSize / zoomScale(canonical.z); + const unwrappedX = canonical.x + Math.pow(2, canonical.z) * unwrappedTileID.wrap; + + const worldMatrix = mat4.identity(new Float64Array(16) as any); + mat4.translate(worldMatrix, worldMatrix, [unwrappedX * scale, canonical.y * scale, 0]); + mat4.scale(worldMatrix, worldMatrix, [scale / EXTENT, scale / EXTENT, 1]); + return worldMatrix; +} diff --git a/src/geo/projection/projection.ts b/src/geo/projection/projection.ts index 61b858bb05..b4e83aeca4 100644 --- a/src/geo/projection/projection.ts +++ b/src/geo/projection/projection.ts @@ -1,72 +1,121 @@ -import type {Tile} from '../../source/tile'; -import {pixelsToTileUnits} from '../../source/pixels_to_tile_units'; -import type {PointProjection} from '../../symbol/projection'; +import type {CanonicalTileID} from '../../source/tile_id'; +import type {PreparedShader} from '../../shaders/shaders'; +import type {Context} from '../../gl/context'; +import type {Mesh} from '../../render/mesh'; +import type {Program} from '../../render/program'; +import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings'; +import {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec'; /** - * A greatly reduced version of the `Projection` interface from the globe branch, - * used to port symbol bugfixes over to the main branch. Will be replaced with - * the proper interface once globe is merged. + * Custom projections are handled both by a class which implements this `Projection` interface, + * and a class that is derived from the `Transform` base class. What is the difference? + * + * The transform-derived class: + * - should do all the heavy lifting for the projection - implement all the `project*` and `unproject*` functions, etc. + * - must store the map's state - center, pitch, etc. - this is handled in the `Transform` base class + * - must be cloneable - it should not create any heavy resources + * + * The projection-implementing class: + * - must provide basic information and data about the projection, which is *independent of the map's state* - name, shader functions, subdivision settings, etc. + * - must be a "singleton" - no matter how many copies of the matching Transform class exist, the Projection should always exist as a single instance (per Map) + * - may create heavy resources that should not exist in multiple copies (projection is never cloned) - for example, see the GPU inaccuracy mitigation for globe projection + * - must be explicitly disposed of after usage using the `destroy` function - this allows the implementing class to free any allocated resources + */ + +/** + * @internal */ -export type Projection = { - useSpecialProjectionForSymbols: boolean; - isOccluded(_x, _y, _t): boolean; - projectTileCoordinates(_x, _y, _t, _ele): PointProjection; - getPitchedTextCorrection(_transform, _anchor, _tile): number; - translatePosition(transform: { angle: number; zoom: number }, tile: Tile, translate: [number, number], translateAnchor: 'map' | 'viewport'): [number, number]; - getCircleRadiusCorrection(tr: any): number; +export type ProjectionGPUContext = { + context: Context; + useProgram: (name: string) => Program; }; -export function createProjection(): Projection { - return { - isOccluded(_x: any, _y: any, _t: any) { - return false; - }, - getPitchedTextCorrection(_transform: any, _anchor: any, _tile: any) { - return 1.0; - }, - get useSpecialProjectionForSymbols() { return false; }, - projectTileCoordinates(_x, _y, _t, _ele) { - // This function should only be used when useSpecialProjectionForSymbols is set to true. - throw new Error('Not implemented.'); - }, - translatePosition(transform, tile, translate, translateAnchor) { - return translatePosition(transform, tile, translate, translateAnchor); - }, - getCircleRadiusCorrection(_: any) { - return 1.0; - } - }; -} +/** + * @internal + * Specifies the usage for a square tile mesh: + * - 'stencil' for drawing stencil masks + * - 'raster' for drawing raster tiles, hillshade, etc. + */ +export type TileMeshUsage = 'stencil' | 'raster'; /** - * 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. - * - * Temporarily imported from globe branch. + * An interface the implementations of which are used internally by MapLibre to handle different projections. */ -function translatePosition( - transform: { angle: number; zoom: number }, - tile: Tile, - translate: [number, number], - translateAnchor: 'map' | 'viewport', - inViewportPixelUnitsUnits: boolean = false -): [number, number] { - if (!translate[0] && !translate[1]) return [0, 0]; +export interface Projection { + /** + * @internal + * A short, descriptive name of this projection, such as 'mercator' or 'globe'. + */ + get name(): ProjectionSpecification['type']; + + /** + * @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 + * An object describing how much subdivision should be applied to rendered geometry. + * The subdivision settings should be a constant for a given projection. + * Projections that do not require subdivision should return {@link SubdivisionGranularitySetting.noSubdivision}. + */ + get subdivisionGranularity(): SubdivisionGranularitySetting; + + /** + * @internal + * Cleans up any resources the projection created, especially GPU buffers. + */ + destroy(): void; - const angle = inViewportPixelUnitsUnits ? - (translateAnchor === 'map' ? transform.angle : 0) : - (translateAnchor === 'viewport' ? -transform.angle : 0); + /** + * @internal + * True when an animation handled by the projection is in progress, + * requiring MapLibre to keep rendering new frames. + */ + isRenderingDirty(): boolean; - 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 - ]; - } + /** + * @internal + * Runs any GPU-side tasks this projection required. Called at the beginning of every frame. + */ + updateGPUdependent(renderContext: ProjectionGPUContext): void; - return [ - inViewportPixelUnitsUnits ? translate[0] : pixelsToTileUnits(tile, translate[0], transform.zoom), - inViewportPixelUnitsUnits ? translate[1] : pixelsToTileUnits(tile, translate[1], transform.zoom)]; + /** + * @internal + * Returns a subdivided mesh for a given tile ID, covering 0..EXTENT range. + * @param context - WebGL context. + * @param tileID - 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. + * @param allowPoles - When true, the mesh will also include geometry to cover the north (south) pole, if the given tileID borders the mercator range's top (bottom) edge. + * @param usage - Specify the usage of the tile mesh, as different usages might use different levels of subdivision. + */ + getMeshFromTileID(context: Context, tileID: CanonicalTileID, hasBorder: boolean, allowPoles: boolean, usage: TileMeshUsage): Mesh; } diff --git a/src/geo/projection/projection_data.ts b/src/geo/projection/projection_data.ts new file mode 100644 index 0000000000..f2c41c9828 --- /dev/null +++ b/src/geo/projection/projection_data.ts @@ -0,0 +1,46 @@ +import type {mat4} from 'gl-matrix'; + +/** + * This type contains all data necessary to project a tile to screen in MapLibre's shader system. + * Contains data used for both mercator and globe projection. + */ +export type ProjectionData = { + /** + * The main projection matrix. For mercator projection, it usually projects in-tile coordinates 0..EXTENT to screen, + * for globe projection, it projects a unit sphere planet to screen. + * Uniform name: `u_projection_matrix`. + */ + mainMatrix: mat4; + /** + * The extent of current tile in the mercator square. + * Used by globe projection. + * First two components are X and Y offset, last two are X and Y scale. + * Uniform name: `u_projection_tile_mercator_coords`. + * + * Conversion from in-tile coordinates in range 0..EXTENT is done as follows: + * @example + * ``` + * vec2 mercator_coords = u_projection_tile_mercator_coords.xy + in_tile.xy * u_projection_tile_mercator_coords.zw; + * ``` + */ + tileMercatorCoords: [number, number, number, number]; + /** + * The plane equation for a plane that intersects the planet's horizon. + * Assumes the planet to be a unit sphere. + * Used by globe projection for clipping. + * Uniform name: `u_projection_clipping_plane`. + */ + clippingPlane: [number, number, number, number]; + /** + * A value in range 0..1 indicating interpolation between mercator (0) and globe (1) projections. + * Used by globe projection to hide projection transition at high zooms. + * Uniform name: `u_projection_transition`. + */ + projectionTransition: number; + /** + * Fallback matrix that projects the current tile according to mercator projection. + * Used by globe projection to fall back to mercator projection in an animated way. + * Uniform name: `u_projection_fallback_matrix`. + */ + fallbackMatrix: mat4; +} diff --git a/src/geo/projection/projection_factory.ts b/src/geo/projection/projection_factory.ts new file mode 100644 index 0000000000..12ae6057df --- /dev/null +++ b/src/geo/projection/projection_factory.ts @@ -0,0 +1,46 @@ +import {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {warnOnce} from '../../util/util'; +import {Projection} from './projection'; +import {ITransform} from '../transform_interface'; +import {ICameraHelper} from './camera_helper'; +import {MercatorProjection} from './mercator'; +import {MercatorTransform} from './mercator_transform'; +import {MercatorCameraHelper} from './mercator_camera_helper'; +import {GlobeProjection} from './globe'; +import {GlobeTransform} from './globe_transform'; +import {GlobeCameraHelper} from './globe_camera_helper'; + +export function createProjectionFromName(name: ProjectionSpecification['type']): { + projection: Projection; + transform: ITransform; + cameraHelper: ICameraHelper; +} { + switch (name) { + case 'mercator': + { + return { + projection: new MercatorProjection(), + transform: new MercatorTransform(), + cameraHelper: new MercatorCameraHelper(), + }; + } + case 'globe': + { + const proj = new GlobeProjection(); + return { + projection: proj, + transform: new GlobeTransform(proj), + cameraHelper: new GlobeCameraHelper(proj), + }; + } + default: + { + warnOnce(`Unknown projection name: ${name}. Falling back to mercator projection.`); + return { + projection: new MercatorProjection(), + transform: new MercatorTransform(), + cameraHelper: new MercatorCameraHelper(), + }; + } + } +} diff --git a/src/geo/transform.ts b/src/geo/transform.ts deleted file mode 100644 index d832820150..0000000000 --- a/src/geo/transform.ts +++ /dev/null @@ -1,1072 +0,0 @@ -import {LngLat} from './lng_lat'; -import {LngLatBounds} from './lng_lat_bounds'; -import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from './mercator_coordinate'; -import Point from '@mapbox/point-geometry'; -import {wrap, clamp} from '../util/util'; -import {interpolates} from '@maplibre/maplibre-gl-style-spec'; -import {EXTENT} from '../data/extent'; -import {vec3, vec4, mat4, mat2, vec2} from 'gl-matrix'; -import {Aabb, Frustum} from '../util/primitives'; -import {EdgeInsets} from './edge_insets'; - -import {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../source/tile_id'; -import type {PaddingOptions} from './edge_insets'; -import {Terrain} from '../render/terrain'; - -export const MAX_VALID_LATITUDE = 85.051129; - -/** - * @internal - * A single transform, generally used for a single tile to be - * scaled, rotated, and zoomed. - */ -export class Transform { - tileSize: number; - tileZoom: number; - lngRange: [number, number]; - latRange: [number, number]; - scale: number; - width: number; - height: number; - angle: number; - rotationMatrix: mat2; - pixelsToGLUnits: [number, number]; - cameraToCenterDistance: number; - mercatorMatrix: mat4; - projectionMatrix: mat4; - modelViewProjectionMatrix: mat4; - invModelViewProjectionMatrix: mat4; - alignedModelViewProjectionMatrix: mat4; - fogMatrix: mat4; - pixelMatrix: mat4; - pixelMatrix3D: mat4; - pixelMatrixInverse: mat4; - glCoordMatrix: mat4; - labelPlaneMatrix: mat4; - minElevationForCurrentTile: number; - _fov: number; - _pitch: number; - _zoom: number; - _unmodified: boolean; - _renderWorldCopies: boolean; - _minZoom: number; - _maxZoom: number; - _minPitch: number; - _maxPitch: number; - _center: LngLat; - _elevation: number; - _pixelPerMeter: number; - _edgeInsets: EdgeInsets; - _constraining: boolean; - _posMatrixCache: {[_: string]: mat4}; - _alignedPosMatrixCache: {[_: string]: mat4}; - _fogMatrixCache: {[_: string]: mat4}; - /** - * This value represents the distance from the camera to the far clipping plane. - * It is used in the calculation of the projection matrix to determine which objects are visible. - * farZ should be larger than nearZ. - */ - farZ: number; - /** - * This value represents the distance from the camera to the near clipping plane. - * It is used in the calculation of the projection matrix to determine which objects are visible. - * nearZ should be smaller than farZ. - */ - nearZ: number; - - constructor(minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) { - this.tileSize = 512; // constant - - this._renderWorldCopies = renderWorldCopies === undefined ? true : !!renderWorldCopies; - this._minZoom = minZoom || 0; - this._maxZoom = maxZoom || 22; - - this._minPitch = (minPitch === undefined || minPitch === null) ? 0 : minPitch; - this._maxPitch = (maxPitch === undefined || maxPitch === null) ? 60 : maxPitch; - - this.setMaxBounds(); - - this.width = 0; - this.height = 0; - this._center = new LngLat(0, 0); - this._elevation = 0; - this.zoom = 0; - this.angle = 0; - this._fov = 0.6435011087932844; - this._pitch = 0; - this._unmodified = true; - this._edgeInsets = new EdgeInsets(); - this._posMatrixCache = {}; - this._alignedPosMatrixCache = {}; - this._fogMatrixCache = {}; - this.minElevationForCurrentTile = 0; - } - - clone(): Transform { - const clone = new Transform(this._minZoom, this._maxZoom, this._minPitch, this.maxPitch, this._renderWorldCopies); - clone.apply(this); - return clone; - } - - apply(that: Transform) { - this.tileSize = that.tileSize; - this.latRange = that.latRange; - this.lngRange = that.lngRange; - this.width = that.width; - this.height = that.height; - this._center = that._center; - this._elevation = that._elevation; - this.minElevationForCurrentTile = that.minElevationForCurrentTile; - this.zoom = that.zoom; - this.angle = that.angle; - this._fov = that._fov; - this._pitch = that._pitch; - this._unmodified = that._unmodified; - this._edgeInsets = that._edgeInsets.clone(); - this._calcMatrices(); - } - - get minZoom(): number { return this._minZoom; } - set minZoom(zoom: number) { - if (this._minZoom === zoom) return; - this._minZoom = zoom; - this.zoom = Math.max(this.zoom, zoom); - } - - get maxZoom(): number { return this._maxZoom; } - set maxZoom(zoom: number) { - if (this._maxZoom === zoom) return; - this._maxZoom = zoom; - this.zoom = Math.min(this.zoom, zoom); - } - - get minPitch(): number { return this._minPitch; } - set minPitch(pitch: number) { - if (this._minPitch === pitch) return; - this._minPitch = pitch; - this.pitch = Math.max(this.pitch, pitch); - } - - get maxPitch(): number { return this._maxPitch; } - set maxPitch(pitch: number) { - if (this._maxPitch === pitch) return; - this._maxPitch = pitch; - this.pitch = Math.min(this.pitch, pitch); - } - - get renderWorldCopies(): boolean { return this._renderWorldCopies; } - set renderWorldCopies(renderWorldCopies: boolean) { - if (renderWorldCopies === undefined) { - renderWorldCopies = true; - } else if (renderWorldCopies === null) { - renderWorldCopies = false; - } - - this._renderWorldCopies = renderWorldCopies; - } - - get worldSize(): number { - return this.tileSize * this.scale; - } - - get centerOffset(): Point { - return this.centerPoint._sub(this.size._div(2)); - } - - get size(): Point { - return new Point(this.width, this.height); - } - - get bearing(): number { - return -this.angle / Math.PI * 180; - } - set bearing(bearing: number) { - const b = -wrap(bearing, -180, 180) * Math.PI / 180; - if (this.angle === b) return; - this._unmodified = false; - this.angle = b; - this._calcMatrices(); - - // 2x2 matrix for rotating points - this.rotationMatrix = mat2.create(); - mat2.rotate(this.rotationMatrix, this.rotationMatrix, this.angle); - } - - get pitch(): number { - return this._pitch / Math.PI * 180; - } - set pitch(pitch: number) { - const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI; - if (this._pitch === p) return; - this._unmodified = false; - this._pitch = p; - this._calcMatrices(); - } - - get fov(): number { - return this._fov / Math.PI * 180; - } - set fov(fov: number) { - fov = Math.max(0.01, Math.min(60, fov)); - if (this._fov === fov) return; - this._unmodified = false; - this._fov = fov / 180 * Math.PI; - this._calcMatrices(); - } - - get zoom(): number { return this._zoom; } - set zoom(zoom: number) { - const constrainedZoom = Math.min(Math.max(zoom, this.minZoom), this.maxZoom); - if (this._zoom === constrainedZoom) return; - this._unmodified = false; - this._zoom = constrainedZoom; - this.tileZoom = Math.max(0, Math.floor(constrainedZoom)); - this.scale = this.zoomScale(constrainedZoom); - this._constrain(); - this._calcMatrices(); - } - - get center(): LngLat { return this._center; } - set center(center: LngLat) { - if (center.lat === this._center.lat && center.lng === this._center.lng) return; - this._unmodified = false; - this._center = center; - this._constrain(); - this._calcMatrices(); - } - - /** - * Elevation at current center point, meters above sea level - */ - get elevation(): number { return this._elevation; } - set elevation(elevation: number) { - if (elevation === this._elevation) return; - this._elevation = elevation; - this._constrain(); - this._calcMatrices(); - } - - get padding(): PaddingOptions { return this._edgeInsets.toJSON(); } - set padding(padding: PaddingOptions) { - if (this._edgeInsets.equals(padding)) return; - this._unmodified = false; - //Update edge-insets in place - this._edgeInsets.interpolate(this._edgeInsets, padding, 1); - this._calcMatrices(); - } - - /** - * The center of the screen in pixels with the top-left corner being (0,0) - * and +y axis pointing downwards. This accounts for padding. - */ - get centerPoint(): Point { - return this._edgeInsets.getCenter(this.width, this.height); - } - - /** - * Returns if the padding params match - * - * @param padding - the padding to check against - * @returns true if they are equal, false otherwise - */ - isPaddingEqual(padding: PaddingOptions): boolean { - return this._edgeInsets.equals(padding); - } - - /** - * Helper method to update edge-insets in place - * - * @param start - the starting padding - * @param target - the target padding - * @param t - the step/weight - */ - interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number) { - this._unmodified = false; - this._edgeInsets.interpolate(start, target, t); - this._constrain(); - this._calcMatrices(); - } - - /** - * Return a zoom level that will cover all tiles the transform - * @param options - the options - * @returns zoom level An integer zoom level at which all tiles will be visible. - */ - coveringZoomLevel(options: { - /** - * Target zoom level. If true, the value will be rounded to the closest integer. Otherwise the value will be floored. - */ - roundZoom?: boolean; - /** - * Tile size, expressed in screen pixels. - */ - tileSize: number; - }): number { - const z = (options.roundZoom ? Math.round : Math.floor)( - this.zoom + this.scaleZoom(this.tileSize / options.tileSize) - ); - // At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist. - return Math.max(0, z); - } - - /** - * Return any "wrapped" copies of a given tile coordinate that are visible - * in the current view. - */ - getVisibleUnwrappedCoordinates(tileID: CanonicalTileID) { - const result = [new UnwrappedTileID(0, tileID)]; - if (this._renderWorldCopies) { - const utl = this.pointCoordinate(new Point(0, 0)); - const utr = this.pointCoordinate(new Point(this.width, 0)); - const ubl = this.pointCoordinate(new Point(this.width, this.height)); - const ubr = this.pointCoordinate(new Point(0, this.height)); - const w0 = Math.floor(Math.min(utl.x, utr.x, ubl.x, ubr.x)); - const w1 = Math.floor(Math.max(utl.x, utr.x, ubl.x, ubr.x)); - - // Add an extra copy of the world on each side to properly render ImageSources and CanvasSources. - // Both sources draw outside the tile boundaries of the tile that "contains them" so we need - // to add extra copies on both sides in case offscreen tiles need to draw into on-screen ones. - const extraWorldCopy = 1; - - for (let w = w0 - extraWorldCopy; w <= w1 + extraWorldCopy; w++) { - if (w === 0) continue; - result.push(new UnwrappedTileID(w, tileID)); - } - } - return result; - } - - /** - * Return all coordinates that could cover this transform for a covering - * zoom level. - * @param options - the options - * @returns OverscaledTileIDs - */ - coveringTiles( - options: { - tileSize: number; - minzoom?: number; - maxzoom?: number; - roundZoom?: boolean; - reparseOverscaled?: boolean; - renderWorldCopies?: boolean; - terrain?: Terrain; - } - ): Array { - let z = this.coveringZoomLevel(options); - const actualZ = z; - - if (options.minzoom !== undefined && z < options.minzoom) return []; - if (options.maxzoom !== undefined && z > options.maxzoom) z = options.maxzoom; - - const cameraCoord = this.pointCoordinate(this.getCameraPoint()); - const centerCoord = MercatorCoordinate.fromLngLat(this.center); - const numTiles = Math.pow(2, z); - const cameraPoint = [numTiles * cameraCoord.x, numTiles * cameraCoord.y, 0]; - const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0]; - const cameraFrustum = Frustum.fromInvProjectionMatrix(this.invModelViewProjectionMatrix, this.worldSize, z); - - // No change of LOD behavior for pitch lower than 60 and when there is no top padding: return only tile ids from the requested zoom level - let minZoom = options.minzoom || 0; - // Use 0.1 as an epsilon to avoid for explicit == 0.0 floating point checks - if (!options.terrain && this.pitch <= 60.0 && this._edgeInsets.top < 0.1) - minZoom = z; - - // There should always be a certain number of maximum zoom level tiles surrounding the center location in 2D or in front of the camera in 3D - const radiusOfMaxLvlLodInTiles = options.terrain ? 2 / Math.min(this.tileSize, options.tileSize) * this.tileSize : 3; - - const newRootTile = (wrap: number): any => { - return { - aabb: new Aabb([wrap * numTiles, 0, 0], [(wrap + 1) * numTiles, numTiles, 0]), - zoom: 0, - x: 0, - y: 0, - wrap, - fullyVisible: false - }; - }; - - // Do a depth-first traversal to find visible tiles and proper levels of detail - const stack = []; - const result = []; - const maxZoom = z; - const overscaledZ = options.reparseOverscaled ? actualZ : z; - - if (this._renderWorldCopies) { - // Render copy of the globe thrice on both sides - for (let i = 1; i <= 3; i++) { - stack.push(newRootTile(-i)); - stack.push(newRootTile(i)); - } - } - - stack.push(newRootTile(0)); - - while (stack.length > 0) { - const it = stack.pop(); - const x = it.x; - const y = it.y; - let fullyVisible = it.fullyVisible; - - // Visibility of a tile is not required if any of its ancestor if fully inside the frustum - if (!fullyVisible) { - const intersectResult = it.aabb.intersects(cameraFrustum); - - if (intersectResult === 0) - continue; - - fullyVisible = intersectResult === 2; - } - - const refPoint = options.terrain ? cameraPoint : centerPoint; - const distanceX = it.aabb.distanceX(refPoint); - const distanceY = it.aabb.distanceY(refPoint); - const longestDim = Math.max(Math.abs(distanceX), Math.abs(distanceY)); - - // We're using distance based heuristics to determine if a tile should be split into quadrants or not. - // radiusOfMaxLvlLodInTiles defines that there's always a certain number of maxLevel tiles next to the map center. - // Using the fact that a parent node in quadtree is twice the size of its children (per dimension) - // we can define distance thresholds for each relative level: - // f(k) = offset + 2 + 4 + 8 + 16 + ... + 2^k. This is the same as "offset+2^(k+1)-2" - const distToSplit = radiusOfMaxLvlLodInTiles + (1 << (maxZoom - it.zoom)) - 2; - - // Have we reached the target depth or is the tile too far away to be any split further? - if (it.zoom === maxZoom || (longestDim > distToSplit && it.zoom >= minZoom)) { - const dz = maxZoom - it.zoom, dx = cameraPoint[0] - 0.5 - (x << dz), dy = cameraPoint[1] - 0.5 - (y << dz); - result.push({ - tileID: new OverscaledTileID(it.zoom === maxZoom ? overscaledZ : it.zoom, it.wrap, it.zoom, x, y), - distanceSq: vec2.sqrLen([centerPoint[0] - 0.5 - x, centerPoint[1] - 0.5 - y]), - // this variable is currently not used, but may be important to reduce the amount of loaded tiles - tileDistanceToCamera: Math.sqrt(dx * dx + dy * dy) - }); - continue; - } - - for (let i = 0; i < 4; i++) { - const childX = (x << 1) + (i % 2); - const childY = (y << 1) + (i >> 1); - const childZ = it.zoom + 1; - let quadrant = it.aabb.quadrant(i); - if (options.terrain) { - const tileID = new OverscaledTileID(childZ, it.wrap, childZ, childX, childY); - const minMax = options.terrain.getMinMaxElevation(tileID); - const minElevation = minMax.minElevation ?? this.elevation; - const maxElevation = minMax.maxElevation ?? this.elevation; - quadrant = new Aabb( - [quadrant.min[0], quadrant.min[1], minElevation] as vec3, - [quadrant.max[0], quadrant.max[1], maxElevation] as vec3 - ); - } - stack.push({aabb: quadrant, zoom: childZ, x: childX, y: childY, wrap: it.wrap, fullyVisible}); - } - } - - return result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID); - } - - resize(width: number, height: number) { - this.width = width; - this.height = height; - - this.pixelsToGLUnits = [2 / width, -2 / height]; - this._constrain(); - this._calcMatrices(); - } - - get unmodified(): boolean { return this._unmodified; } - - zoomScale(zoom: number) { return Math.pow(2, zoom); } - scaleZoom(scale: number) { return Math.log(scale) / Math.LN2; } - - /** - * Convert from LngLat to world coordinates (Mercator coordinates scaled by 512) - * @param lnglat - the lngLat - * @returns Point - */ - project(lnglat: LngLat) { - const lat = clamp(lnglat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE); - return new Point( - mercatorXfromLng(lnglat.lng) * this.worldSize, - mercatorYfromLat(lat) * this.worldSize); - } - - /** - * Convert from world coordinates ([0, 512],[0, 512]) to LngLat ([-180, 180], [-90, 90]) - * @param point - world coordinate - * @returns LngLat - */ - unproject(point: Point): LngLat { - return new MercatorCoordinate(point.x / this.worldSize, point.y / this.worldSize).toLngLat(); - } - - get point(): Point { return this.project(this.center); } - - /** - * get the camera position in LngLat and altitudes in meter - * @returns An object with lngLat & altitude. - */ - getCameraPosition(): { - lngLat: LngLat; - altitude: number; - } { - const lngLat = this.pointLocation(this.getCameraPoint()); - const altitude = Math.cos(this._pitch) * this.cameraToCenterDistance / this._pixelPerMeter; - return {lngLat, altitude: altitude + this.elevation}; - } - - /** - * This method works in combination with freezeElevation activated. - * freezeElevation is enabled during map-panning because during this the camera should sit in constant height. - * After panning finished, call this method to recalculate the zoomlevel for the current camera-height in current terrain. - * @param terrain - the terrain - */ - recalculateZoom(terrain: Terrain) { - const origElevation = this.elevation; - const origAltitude = Math.cos(this._pitch) * this.cameraToCenterDistance / this._pixelPerMeter; - - // find position the camera is looking on - const center = this.pointLocation(this.centerPoint, terrain); - const elevation = terrain.getElevationForLngLatZoom(center, this.tileZoom); - const deltaElevation = this.elevation - elevation; - if (!deltaElevation) return; - - // The camera's altitude off the ground + the ground's elevation = a constant: - // this means the camera stays at the same total height. - const requiredAltitude = origAltitude + origElevation - elevation; - // Since altitude = Math.cos(this._pitch) * this.cameraToCenterDistance / pixelPerMeter: - const requiredPixelPerMeter = Math.cos(this._pitch) * this.cameraToCenterDistance / requiredAltitude; - // Since pixelPerMeter = mercatorZfromAltitude(1, center.lat) * worldSize: - const requiredWorldSize = requiredPixelPerMeter / mercatorZfromAltitude(1, center.lat); - // Since worldSize = this.tileSize * scale: - const requiredScale = requiredWorldSize / this.tileSize; - const zoom = this.scaleZoom(requiredScale); - - // update matrices - this._elevation = elevation; - this._center = center; - this.zoom = zoom; - } - - setLocationAtPoint(lnglat: LngLat, point: Point) { - const a = this.pointCoordinate(point); - const b = this.pointCoordinate(this.centerPoint); - const loc = this.locationCoordinate(lnglat); - const newCenter = new MercatorCoordinate( - loc.x - (a.x - b.x), - loc.y - (a.y - b.y)); - this.center = this.coordinateLocation(newCenter); - if (this._renderWorldCopies) { - this.center = this.center.wrap(); - } - } - - /** - * Given a LngLat location, return the screen point that corresponds to it - * @param lnglat - location - * @param terrain - optional terrain - * @returns screen point - */ - locationPoint(lnglat: LngLat, terrain?: Terrain): Point { - return terrain ? - this.coordinatePoint(this.locationCoordinate(lnglat), terrain.getElevationForLngLatZoom(lnglat, this.tileZoom), this.pixelMatrix3D) : - this.coordinatePoint(this.locationCoordinate(lnglat)); - } - - /** - * Given a point on screen, return its lnglat - * @param p - screen point - * @param terrain - optional terrain - * @returns lnglat location - */ - pointLocation(p: Point, terrain?: Terrain): LngLat { - return this.coordinateLocation(this.pointCoordinate(p, terrain)); - } - - /** - * Given a geographical lnglat, return an unrounded - * coordinate that represents it at low zoom level. - * @param lnglat - the location - * @returns The mercator coordinate - */ - locationCoordinate(lnglat: LngLat): MercatorCoordinate { - return MercatorCoordinate.fromLngLat(lnglat); - } - - /** - * Given a Coordinate, return its geographical position. - * @param coord - mercator coordinates - * @returns lng and lat - */ - coordinateLocation(coord: MercatorCoordinate): LngLat { - return coord && coord.toLngLat(); - } - - /** - * Given a Point, return its mercator coordinate. - * @param p - the point - * @param terrain - optional terrain - * @returns lnglat - */ - pointCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate { - // get point-coordinate from terrain coordinates framebuffer - if (terrain) { - const coordinate = terrain.pointCoordinate(p); - if (coordinate != null) { - return coordinate; - } - } - - // calculate point-coordinate on flat earth - const targetZ = 0; - // since we don't know the correct projected z value for the point, - // unproject two points to get a line and then find the point on that - // line with z=0 - - const coord0 = [p.x, p.y, 0, 1] as vec4; - const coord1 = [p.x, p.y, 1, 1] as vec4; - - vec4.transformMat4(coord0, coord0, this.pixelMatrixInverse); - vec4.transformMat4(coord1, coord1, this.pixelMatrixInverse); - - const w0 = coord0[3]; - const w1 = coord1[3]; - const x0 = coord0[0] / w0; - const x1 = coord1[0] / w1; - const y0 = coord0[1] / w0; - const y1 = coord1[1] / w1; - const z0 = coord0[2] / w0; - const z1 = coord1[2] / w1; - - const t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0); - - return new MercatorCoordinate( - interpolates.number(x0, x1, t) / this.worldSize, - interpolates.number(y0, y1, t) / this.worldSize); - } - - /** - * Given a coordinate, return the screen point that corresponds to it - * @param coord - the coordinates - * @param elevation - the elevation - * @param pixelMatrix - the pixel matrix - * @returns screen point - */ - coordinatePoint(coord: MercatorCoordinate, elevation: number = 0, pixelMatrix = this.pixelMatrix): Point { - const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4; - vec4.transformMat4(p, p, pixelMatrix); - return new Point(p[0] / p[3], p[1] / p[3]); - } - - /** - * Returns the map's geographical bounds. When the bearing or pitch is non-zero, the visible region is not - * an axis-aligned rectangle, and the result is the smallest bounds that encompasses the visible region. - * @returns Returns a {@link LngLatBounds} object describing the map's geographical bounds. - */ - getBounds(): LngLatBounds { - const top = Math.max(0, this.height / 2 - this.getHorizon()); - return new LngLatBounds() - .extend(this.pointLocation(new Point(0, top))) - .extend(this.pointLocation(new Point(this.width, top))) - .extend(this.pointLocation(new Point(this.width, this.height))) - .extend(this.pointLocation(new Point(0, this.height))); - } - - /** - * Returns the maximum geographical bounds the map is constrained to, or `null` if none set. - * @returns max bounds - */ - getMaxBounds(): LngLatBounds | null { - if (!this.latRange || this.latRange.length !== 2 || - !this.lngRange || this.lngRange.length !== 2) return null; - - return new LngLatBounds([this.lngRange[0], this.latRange[0]], [this.lngRange[1], this.latRange[1]]); - } - - /** - * Calculate pixel height of the visible horizon in relation to map-center (e.g. height/2), - * multiplied by a static factor to simulate the earth-radius. - * The calculated value is the horizontal line from the camera-height to sea-level. - * @returns Horizon above center in pixels. - */ - getHorizon(): number { - return Math.tan(Math.PI / 2 - this._pitch) * this.cameraToCenterDistance * 0.85; - } - - /** - * Sets or clears the map's geographical constraints. - * @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map. - */ - setMaxBounds(bounds?: LngLatBounds | null) { - if (bounds) { - this.lngRange = [bounds.getWest(), bounds.getEast()]; - this.latRange = [bounds.getSouth(), bounds.getNorth()]; - this._constrain(); - } else { - this.lngRange = null; - this.latRange = [-MAX_VALID_LATITUDE, MAX_VALID_LATITUDE]; - } - } - - calculateTileMatrix(unwrappedTileID: UnwrappedTileID): mat4 { - const canonical = unwrappedTileID.canonical; - const scale = this.worldSize / this.zoomScale(canonical.z); - const unwrappedX = canonical.x + Math.pow(2, canonical.z) * unwrappedTileID.wrap; - - const worldMatrix = mat4.identity(new Float64Array(16) as any); - mat4.translate(worldMatrix, worldMatrix, [unwrappedX * scale, canonical.y * scale, 0]); - mat4.scale(worldMatrix, worldMatrix, [scale / EXTENT, scale / EXTENT, 1]); - return worldMatrix; - } - - /** - * Calculate the posMatrix that, given a tile coordinate, would be used to display the tile on a map. - * @param unwrappedTileID - the tile ID - */ - calculatePosMatrix(unwrappedTileID: UnwrappedTileID, aligned: boolean = false): mat4 { - const posMatrixKey = unwrappedTileID.key; - const cache = aligned ? this._alignedPosMatrixCache : this._posMatrixCache; - if (cache[posMatrixKey]) { - return cache[posMatrixKey]; - } - - const posMatrix = this.calculateTileMatrix(unwrappedTileID); - mat4.multiply(posMatrix, aligned ? this.alignedModelViewProjectionMatrix : this.modelViewProjectionMatrix, posMatrix); - - cache[posMatrixKey] = new Float32Array(posMatrix); - return cache[posMatrixKey]; - } - - /** - * Calculate the fogMatrix that, given a tile coordinate, would be used to calculate fog on the map. - * @param unwrappedTileID - the tile ID - * @private - */ - calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4 { - const posMatrixKey = unwrappedTileID.key; - const cache = this._fogMatrixCache; - if (cache[posMatrixKey]) { - return cache[posMatrixKey]; - } - - const fogMatrix = this.calculateTileMatrix(unwrappedTileID); - mat4.multiply(fogMatrix, this.fogMatrix, fogMatrix); - - cache[posMatrixKey] = new Float32Array(fogMatrix); - return cache[posMatrixKey]; - } - - customLayerMatrix(): mat4 { - return this.mercatorMatrix.slice() as any; - } - - /** - * Get center lngLat and zoom to ensure that - * 1) everything beyond the bounds is excluded - * 2) a given lngLat is as near the center as possible - * Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian. - */ - getConstrained(lngLat: LngLat, zoom: number): {center: LngLat; zoom: number} { - zoom = clamp(+zoom, this.minZoom, this.maxZoom); - const result = { - center: new LngLat(lngLat.lng, lngLat.lat), - zoom - }; - - let lngRange = this.lngRange; - - if (!this._renderWorldCopies && lngRange === null) { - const almost180 = 180 - 1e-10; - lngRange = [-almost180, almost180]; - } - - const worldSize = this.tileSize * this.zoomScale(result.zoom); // A world size for the requested zoom level, not the current world size - let minY = 0; - let maxY = worldSize; - let minX = 0; - let maxX = worldSize; - let scaleY = 0; - let scaleX = 0; - const {x: screenWidth, y: screenHeight} = this.size; - - if (this.latRange) { - const latRange = this.latRange; - minY = mercatorYfromLat(latRange[1]) * worldSize; - maxY = mercatorYfromLat(latRange[0]) * worldSize; - const shouldZoomIn = maxY - minY < screenHeight; - if (shouldZoomIn) scaleY = screenHeight / (maxY - minY); - } - - if (lngRange) { - minX = wrap( - mercatorXfromLng(lngRange[0]) * worldSize, - 0, - worldSize - ); - maxX = wrap( - mercatorXfromLng(lngRange[1]) * worldSize, - 0, - worldSize - ); - - if (maxX < minX) maxX += worldSize; - - const shouldZoomIn = maxX - minX < screenWidth; - if (shouldZoomIn) scaleX = screenWidth / (maxX - minX); - } - - const {x: originalX, y: originalY} = this.project.call({worldSize}, lngLat); - let modifiedX, modifiedY; - - const scale = Math.max(scaleX || 0, scaleY || 0); - - if (scale) { - // zoom in to exclude all beyond the given lng/lat ranges - const newPoint = new Point( - scaleX ? (maxX + minX) / 2 : originalX, - scaleY ? (maxY + minY) / 2 : originalY); - result.center = this.unproject.call({worldSize}, newPoint).wrap(); - result.zoom += this.scaleZoom(scale); - return result; - } - - if (this.latRange) { - const h2 = screenHeight / 2; - if (originalY - h2 < minY) modifiedY = minY + h2; - if (originalY + h2 > maxY) modifiedY = maxY - h2; - } - - if (lngRange) { - const centerX = (minX + maxX) / 2; - let wrappedX = originalX; - if (this._renderWorldCopies) { - wrappedX = wrap(originalX, centerX - worldSize / 2, centerX + worldSize / 2); - } - const w2 = screenWidth / 2; - - if (wrappedX - w2 < minX) modifiedX = minX + w2; - if (wrappedX + w2 > maxX) modifiedX = maxX - w2; - } - - // pan the map if the screen goes off the range - if (modifiedX !== undefined || modifiedY !== undefined) { - const newPoint = new Point(modifiedX ?? originalX, modifiedY ?? originalY); - result.center = this.unproject.call({worldSize}, newPoint).wrap(); - } - - return result; - } - - _constrain() { - if (!this.center || !this.width || !this.height || this._constraining) return; - this._constraining = true; - const unmodified = this._unmodified; - const {center, zoom} = this.getConstrained(this.center, this.zoom); - this.center = center; - this.zoom = zoom; - this._unmodified = unmodified; - this._constraining = false; - } - - _calcMatrices() { - if (!this.height) return; - - const halfFov = this._fov / 2; - const offset = this.centerOffset; - const x = this.point.x, y = this.point.y; - this.cameraToCenterDistance = 0.5 / Math.tan(halfFov) * this.height; - this._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; - - let m = mat4.identity(new Float64Array(16) as any); - mat4.scale(m, m, [this.width / 2, -this.height / 2, 1]); - mat4.translate(m, m, [1, -1, 0]); - this.labelPlaneMatrix = m; - - m = mat4.identity(new Float64Array(16) as any); - mat4.scale(m, m, [1, -1, 1]); - mat4.translate(m, m, [-1, -1, 0]); - mat4.scale(m, m, [2 / this.width, 2 / this.height, 1]); - this.glCoordMatrix = m; - - // Calculate the camera to sea-level distance in pixel in respect of terrain - const cameraToSeaLevelDistance = this.cameraToCenterDistance + this._elevation * this._pixelPerMeter / Math.cos(this._pitch); - // In case of negative minimum elevation (e.g. the dead see, under the sea maps) use a lower plane for calculation - const minElevation = Math.min(this.elevation, this.minElevationForCurrentTile); - const cameraToLowestPointDistance = cameraToSeaLevelDistance - minElevation * this._pixelPerMeter / Math.cos(this._pitch); - const lowestPlane = minElevation < 0 ? cameraToLowestPointDistance : cameraToSeaLevelDistance; - - // Find the distance from the center point [width/2 + offset.x, height/2 + offset.y] to the - // center top point [width/2 + offset.x, 0] in Z units, using the law of sines. - // 1 Z unit is equivalent to 1 horizontal px at the center of the map - // (the distance between[width/2, height/2] and [width/2 + 1, height/2]) - const groundAngle = Math.PI / 2 + this._pitch; - const fovAboveCenter = this._fov * (0.5 + offset.y / this.height); - const topHalfSurfaceDistance = Math.sin(fovAboveCenter) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01)); - - // Find the distance from the center point to the horizon - const horizon = this.getHorizon(); - const horizonAngle = Math.atan(horizon / this.cameraToCenterDistance); - const fovCenterToHorizon = 2 * horizonAngle * (0.5 + offset.y / (horizon * 2)); - const topHalfSurfaceDistanceHorizon = Math.sin(fovCenterToHorizon) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovCenterToHorizon, 0.01, Math.PI - 0.01)); - - // Calculate z distance of the farthest fragment that should be rendered. - // Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance` - const topHalfMinDistance = Math.min(topHalfSurfaceDistance, topHalfSurfaceDistanceHorizon); - this.farZ = (Math.cos(Math.PI / 2 - this._pitch) * topHalfMinDistance + lowestPlane) * 1.01; - - // The larger the value of nearZ is - // - the more depth precision is available for features (good) - // - clipping starts appearing sooner when the camera is close to 3d features (bad) - // - // Other values work for mapbox-gl-js but deck.gl was encountering precision issues - // when rendering custom layers. This value was experimentally chosen and - // seems to solve z-fighting issues in deck.gl while not clipping buildings too close to the camera. - this.nearZ = this.height / 50; - - // matrix for conversion from location to clip space(-1 .. 1) - m = new Float64Array(16) as any; - mat4.perspective(m, this._fov, this.width / this.height, this.nearZ, this.farZ); - - // Apply center of perspective offset - m[8] = -offset.x * 2 / this.width; - m[9] = offset.y * 2 / this.height; - this.projectionMatrix = mat4.clone(m); - - mat4.scale(m, m, [1, -1, 1]); - mat4.translate(m, m, [0, 0, -this.cameraToCenterDistance]); - mat4.rotateX(m, m, this._pitch); - mat4.rotateZ(m, m, this.angle); - mat4.translate(m, m, [-x, -y, 0]); - - // The mercatorMatrix can be used to transform points from mercator coordinates - // ([0, 0] nw, [1, 1] se) to clip space. - this.mercatorMatrix = mat4.scale([] as any, m, [this.worldSize, this.worldSize, this.worldSize]); - - // scale vertically to meters per pixel (inverse of ground resolution): - mat4.scale(m, m, [1, 1, this._pixelPerMeter]); - - // matrix for conversion from world space to screen coordinates in 2D - this.pixelMatrix = mat4.multiply(new Float64Array(16) as any, this.labelPlaneMatrix, m); - - // matrix for conversion from world space to clip space (-1 .. 1) - mat4.translate(m, m, [0, 0, -this.elevation]); // elevate camera over terrain - this.modelViewProjectionMatrix = m; - this.invModelViewProjectionMatrix = mat4.invert([] as any, m); - - // create a fog matrix, same es proj-matrix but with near clipping-plane in mapcenter - // needed to calculate a correct z-value for fog calculation, because projMatrix z value is not - this.fogMatrix = new Float64Array(16) as any; - mat4.perspective(this.fogMatrix, this._fov, this.width / this.height, cameraToSeaLevelDistance, this.farZ); - this.fogMatrix[8] = -offset.x * 2 / this.width; - this.fogMatrix[9] = offset.y * 2 / this.height; - mat4.scale(this.fogMatrix, this.fogMatrix, [1, -1, 1]); - mat4.translate(this.fogMatrix, this.fogMatrix, [0, 0, -this.cameraToCenterDistance]); - mat4.rotateX(this.fogMatrix, this.fogMatrix, this._pitch); - mat4.rotateZ(this.fogMatrix, this.fogMatrix, this.angle); - mat4.translate(this.fogMatrix, this.fogMatrix, [-x, -y, 0]); - mat4.scale(this.fogMatrix, this.fogMatrix, [1, 1, this._pixelPerMeter]); - mat4.translate(this.fogMatrix, this.fogMatrix, [0, 0, -this.elevation]); // elevate camera over terrain - - // matrix for conversion from world space to screen coordinates in 3D - this.pixelMatrix3D = mat4.multiply(new Float64Array(16) as any, this.labelPlaneMatrix, m); - - // Make a second projection matrix that is aligned to a pixel grid for rendering raster tiles. - // We're rounding the (floating point) x/y values to achieve to avoid rendering raster images to fractional - // coordinates. Additionally, we adjust by half a pixel in either direction in case that viewport dimension - // is an odd integer to preserve rendering to the pixel grid. We're rotating this shift based on the angle - // of the transformation so that 0°, 90°, 180°, and 270° rasters are crisp, and adjust the shift so that - // it is always <= 0.5 pixels. - const xShift = (this.width % 2) / 2, yShift = (this.height % 2) / 2, - angleCos = Math.cos(this.angle), angleSin = Math.sin(this.angle), - dx = x - Math.round(x) + angleCos * xShift + angleSin * yShift, - dy = y - Math.round(y) + angleCos * yShift + angleSin * xShift; - const alignedM = new Float64Array(m) as any as mat4; - mat4.translate(alignedM, alignedM, [dx > 0.5 ? dx - 1 : dx, dy > 0.5 ? dy - 1 : dy, 0]); - this.alignedModelViewProjectionMatrix = alignedM; - - // inverse matrix for conversion from screen coordinates to location - m = mat4.invert(new Float64Array(16) as any, this.pixelMatrix); - if (!m) throw new Error('failed to invert matrix'); - this.pixelMatrixInverse = m; - - this._posMatrixCache = {}; - this._alignedPosMatrixCache = {}; - this._fogMatrixCache = {}; - } - - maxPitchScaleFactor() { - // calcMatrices hasn't run yet - if (!this.pixelMatrixInverse) return 1; - - const coord = this.pointCoordinate(new Point(0, 0)); - const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1] as vec4; - const topPoint = vec4.transformMat4(p, p, this.pixelMatrix); - return topPoint[3] / this.cameraToCenterDistance; - } - - /** - * The camera looks at the map from a 3D (lng, lat, altitude) location. Let's use `cameraLocation` - * as the name for the location under the camera and on the surface of the earth (lng, lat, 0). - * `cameraPoint` is the projected position of the `cameraLocation`. - * - * This point is useful to us because only fill-extrusions that are between `cameraPoint` and - * the query point on the surface of the earth can extend and intersect the query. - * - * When the map is not pitched the `cameraPoint` is equivalent to the center of the map because - * the camera is right above the center of the map. - */ - getCameraPoint() { - const pitch = this._pitch; - const yOffset = Math.tan(pitch) * (this.cameraToCenterDistance || 1); - return this.centerPoint.add(new Point(0, yOffset)); - } - - /** - * When the map is pitched, some of the 3D features that intersect a query will not intersect - * the query at the surface of the earth. Instead the feature may be closer and only intersect - * the query because it extrudes into the air. - * @param queryGeometry - For point queries, the line from the query point to the "camera point", - * for other geometries, the envelope of the query geometry and the "camera point" - * @returns a geometry that includes all of the original query as well as all possible ares of the - * screen where the *base* of a visible extrusion could be. - * - */ - getCameraQueryGeometry(queryGeometry: Array): Array { - const c = this.getCameraPoint(); - - if (queryGeometry.length === 1) { - return [queryGeometry[0], c]; - } else { - let minX = c.x; - let minY = c.y; - let maxX = c.x; - let maxY = c.y; - for (const p of queryGeometry) { - minX = Math.min(minX, p.x); - minY = Math.min(minY, p.y); - maxX = Math.max(maxX, p.x); - maxY = Math.max(maxY, p.y); - } - return [ - new Point(minX, minY), - new Point(maxX, minY), - new Point(maxX, maxY), - new Point(minX, maxY), - new Point(minX, minY) - ]; - } - } - /** - * Return the distance to the camera in clip space from a LngLat. - * This can be compared to the value from the depth buffer (terrain.depthAtPoint) - * to determine whether a point is occluded. - * @param lngLat - the point - * @param elevation - the point's elevation - * @returns depth value in clip space (between 0 and 1) - */ - lngLatToCameraDepth(lngLat: LngLat, elevation: number) { - const coord = this.locationCoordinate(lngLat); - const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4; - vec4.transformMat4(p, p, this.modelViewProjectionMatrix); - return (p[2] / p[3]); - } -} diff --git a/src/geo/transform_helper.test.ts b/src/geo/transform_helper.test.ts new file mode 100644 index 0000000000..066eb81bf7 --- /dev/null +++ b/src/geo/transform_helper.test.ts @@ -0,0 +1,68 @@ +import {LngLat} from './lng_lat'; +import {LngLatBounds} from './lng_lat_bounds'; +import {scaleZoom, TransformHelper, zoomScale} from './transform_helper'; + +describe('TransformHelper', () => { + test('apply', () => { + const emptyCallbacks = { + calcMatrices: () => {}, + getConstrained: (center, zoom) => { return {center, zoom}; }, + }; + + const original = new TransformHelper(emptyCallbacks); + original.setBearing(12); + original.setCenter(new LngLat(3, 4)); + original.setElevation(5); + original.setFov(1); + original.setMaxBounds(new LngLatBounds([-160, -80, 160, 80])); + original.setMaxPitch(50); + original.setMaxZoom(10); + original.setMinElevationForCurrentTile(0.1); + original.setMinPitch(0.1); + original.setMinZoom(0.1); + original.setPadding({ + top: 1, + right: 4, + bottom: 2, + left: 3, + }); + original.setPitch(3); + original.setRenderWorldCopies(false); + original.setZoom(2.3); + + const cloned = new TransformHelper(emptyCallbacks); + cloned.apply(original); + + // Check all getters from the ITransformGetters interface + expect(cloned.tileSize).toEqual(original.tileSize); + expect(cloned.tileZoom).toEqual(original.tileZoom); + expect(cloned.scale).toEqual(original.scale); + expect(cloned.worldSize).toEqual(original.worldSize); + expect(cloned.width).toEqual(original.width); + expect(cloned.height).toEqual(original.height); + expect(cloned.angle).toEqual(original.angle); + expect(cloned.lngRange).toEqual(original.lngRange); + expect(cloned.latRange).toEqual(original.latRange); + expect(cloned.minZoom).toEqual(original.minZoom); + expect(cloned.maxZoom).toEqual(original.maxZoom); + expect(cloned.zoom).toEqual(original.zoom); + expect(cloned.center).toEqual(original.center); + expect(cloned.minPitch).toEqual(original.minPitch); + expect(cloned.maxPitch).toEqual(original.maxPitch); + expect(cloned.pitch).toEqual(original.pitch); + expect(cloned.bearing).toEqual(original.bearing); + expect(cloned.fov).toEqual(original.fov); + expect(cloned.elevation).toEqual(original.elevation); + expect(cloned.minElevationForCurrentTile).toEqual(original.minElevationForCurrentTile); + expect(cloned.padding).toEqual(original.padding); + expect(cloned.unmodified).toEqual(original.unmodified); + expect(cloned.renderWorldCopies).toEqual(original.renderWorldCopies); + }); + + test('scaleZoom+zoomScale', () => { + expect(scaleZoom(0)).toBe(-Infinity); + expect(scaleZoom(10)).toBe(3.3219280948873626); + expect(zoomScale(3.3219280948873626)).toBeCloseTo(10, 10); + expect(scaleZoom(zoomScale(5))).toBe(5); + }); +}); diff --git a/src/geo/transform_helper.ts b/src/geo/transform_helper.ts new file mode 100644 index 0000000000..d45ac78a42 --- /dev/null +++ b/src/geo/transform_helper.ts @@ -0,0 +1,506 @@ +import {LngLat} from './lng_lat'; +import {LngLatBounds} from './lng_lat_bounds'; +import Point from '@mapbox/point-geometry'; +import {wrap, clamp} from '../util/util'; +import {mat4, mat2} from 'gl-matrix'; +import {EdgeInsets} from './edge_insets'; +import type {PaddingOptions} from './edge_insets'; +import {CoveringZoomOptions, IReadonlyTransform, ITransformGetters} from './transform_interface'; + +export const MAX_VALID_LATITUDE = 85.051129; + +/** + * If a path crossing the antimeridian would be shorter, extend the final coordinate so that + * interpolating between the two endpoints will cross it. + * @param center - The LngLat object of the desired center. This object will be mutated. + */ +export function normalizeCenter(tr: IReadonlyTransform, center: LngLat): void { + if (!tr.renderWorldCopies || tr.lngRange) return; + const delta = center.lng - tr.center.lng; + center.lng += + delta > 180 ? -360 : + delta < -180 ? 360 : 0; +} + +/** + * Computes scaling from zoom level. + */ +export function zoomScale(zoom: number) { return Math.pow(2, zoom); } + +/** + * Computes zoom level from scaling. + */ +export function scaleZoom(scale: number) { return Math.log(scale) / Math.LN2; } + +export type UnwrappedTileIDType = { + /** + * Tile wrap: 0 for the "main" world, + * negative values for worlds left of the main, + * positive values for worlds right of the main. + */ + wrap?: number; + canonical: { + /** + * Tile X coordinate, in range 0..(z^2)-1 + */ + x: number; + /** + * Tile Y coordinate, in range 0..(z^2)-1 + */ + y: number; + /** + * Tile zoom level. + */ + z: number; + }; +}; + +export type TransformHelperCallbacks = { + /** + * Get center lngLat and zoom to ensure that + * 1) everything beyond the bounds is excluded + * 2) a given lngLat is as near the center as possible + * Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian. + */ + getConstrained: (center: LngLat, zoom: number) => { center: LngLat; zoom: number }; + + /** + * Updates the underlying transform's internal matrices. + */ + calcMatrices: () => void; +}; + +function getTileZoom(zoom: number): number { + return Math.max(0, Math.floor(zoom)); +} + +/** + * @internal + * This class stores all values that define a transform's state, + * such as center, zoom, minZoom, etc. + * This can be used as a helper for implementing the ITransform interface. + */ +export class TransformHelper implements ITransformGetters { + private _callbacks: TransformHelperCallbacks; + + _tileSize: number; // constant + _tileZoom: number; // integer zoom level for tiles + _lngRange: [number, number]; + _latRange: [number, number]; + _scale: number; // computed based on zoom + _width: number; + _height: number; + /** + * Vertical field of view in radians. + */ + _fov: number; + /** + * This transform's bearing in radians. + * Note that the sign of this variable is *opposite* to the sign of {@link bearing} + */ + _angle: number; + /** + * Pitch in radians. + */ + _pitch: number; + _zoom: number; + _renderWorldCopies: boolean; + _minZoom: number; + _maxZoom: number; + _minPitch: number; + _maxPitch: number; + _center: LngLat; + _elevation: number; + _minElevationForCurrentTile: number; + _pixelPerMeter: number; + _edgeInsets: EdgeInsets; + _unmodified: boolean; + + _constraining: boolean; + _rotationMatrix: mat2; + _pixelsToGLUnits: [number, number]; + _pixelsToClipSpaceMatrix: mat4; + _clipSpaceToPixelsMatrix: mat4; + + constructor(callbacks: TransformHelperCallbacks, minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) { + this._callbacks = callbacks; + this._tileSize = 512; // constant + + this._renderWorldCopies = renderWorldCopies === undefined ? true : !!renderWorldCopies; + this._minZoom = minZoom || 0; + this._maxZoom = maxZoom || 22; + + this._minPitch = (minPitch === undefined || minPitch === null) ? 0 : minPitch; + this._maxPitch = (maxPitch === undefined || maxPitch === null) ? 60 : maxPitch; + + this.setMaxBounds(); + + this._width = 0; + this._height = 0; + this._center = new LngLat(0, 0); + this._elevation = 0; + this._zoom = 0; + this._tileZoom = getTileZoom(this._zoom); + this._scale = zoomScale(this._zoom); + this._angle = 0; + this._fov = 0.6435011087932844; + this._pitch = 0; + this._unmodified = true; + this._edgeInsets = new EdgeInsets(); + this._minElevationForCurrentTile = 0; + } + + public apply(thatI: ITransformGetters, constrain?: boolean): void { + this._latRange = thatI.latRange; + this._lngRange = thatI.lngRange; + this._width = thatI.width; + this._height = thatI.height; + this._center = thatI.center; + this._elevation = thatI.elevation; + this._minElevationForCurrentTile = thatI.minElevationForCurrentTile; + this._zoom = thatI.zoom; + this._tileZoom = getTileZoom(this._zoom); + this._scale = zoomScale(this._zoom); + this._angle = -thatI.bearing * Math.PI / 180; + this._fov = thatI.fov * Math.PI / 180; + this._pitch = thatI.pitch * Math.PI / 180; + this._unmodified = thatI.unmodified; + this._edgeInsets = new EdgeInsets(thatI.padding.top, thatI.padding.bottom, thatI.padding.left, thatI.padding.right); + this._minZoom = thatI.minZoom; + this._maxZoom = thatI.maxZoom; + this._minPitch = thatI.minPitch; + this._maxPitch = thatI.maxPitch; + this._renderWorldCopies = thatI.renderWorldCopies; + if (constrain) { + this._constrain(); + } + this._calcMatrices(); + } + + get pixelsToClipSpaceMatrix(): mat4 { return this._pixelsToClipSpaceMatrix; } + get clipSpaceToPixelsMatrix(): mat4 { return this._clipSpaceToPixelsMatrix; } + + get minElevationForCurrentTile(): number { return this._minElevationForCurrentTile; } + setMinElevationForCurrentTile(ele: number) { + this._minElevationForCurrentTile = ele; + } + + get tileSize(): number { return this._tileSize; } + get tileZoom(): number { return this._tileZoom; } + get scale(): number { return this._scale; } + + /** + * Gets the transform's width in pixels. Use {@link resize} to set the transform's size. + */ + get width(): number { return this._width; } + + /** + * Gets the transform's height in pixels. Use {@link resize} to set the transform's size. + */ + get height(): number { return this._height; } + + /** + * Gets the transform's bearing in radians. + */ + get angle(): number { return this._angle; } + + get lngRange(): [number, number] { return this._lngRange; } + get latRange(): [number, number] { return this._latRange; } + + get pixelsToGLUnits(): [number, number] { return this._pixelsToGLUnits; } + + get minZoom(): number { return this._minZoom; } + setMinZoom(zoom: number) { + if (this._minZoom === zoom) return; + this._minZoom = zoom; + this.setZoom(this.getConstrained(this._center, this.zoom).zoom); + } + + get maxZoom(): number { return this._maxZoom; } + setMaxZoom(zoom: number) { + if (this._maxZoom === zoom) return; + this._maxZoom = zoom; + this.setZoom(this.getConstrained(this._center, this.zoom).zoom); + } + + get minPitch(): number { return this._minPitch; } + setMinPitch(pitch: number) { + if (this._minPitch === pitch) return; + this._minPitch = pitch; + this.setPitch(Math.max(this.pitch, pitch)); + } + + get maxPitch(): number { return this._maxPitch; } + setMaxPitch(pitch: number) { + if (this._maxPitch === pitch) return; + this._maxPitch = pitch; + this.setPitch(Math.min(this.pitch, pitch)); + } + + get renderWorldCopies(): boolean { return this._renderWorldCopies; } + setRenderWorldCopies(renderWorldCopies: boolean) { + if (renderWorldCopies === undefined) { + renderWorldCopies = true; + } else if (renderWorldCopies === null) { + renderWorldCopies = false; + } + + this._renderWorldCopies = renderWorldCopies; + } + + get worldSize(): number { + return this._tileSize * this._scale; + } + + get centerOffset(): Point { + return this.centerPoint._sub(this.size._div(2)); + } + + /** + * Gets the transform's dimensions packed into a Point object. + */ + get size(): Point { + return new Point(this._width, this._height); + } + + get bearing(): number { + return -this._angle / Math.PI * 180; + } + setBearing(bearing: number) { + const b = -wrap(bearing, -180, 180) * Math.PI / 180; + if (this._angle === b) return; + this._unmodified = false; + this._angle = b; + this._calcMatrices(); + + // 2x2 matrix for rotating points + this._rotationMatrix = mat2.create(); + mat2.rotate(this._rotationMatrix, this._rotationMatrix, this._angle); + } + + get rotationMatrix(): mat2 { return this._rotationMatrix; } + + get pitch(): number { + return this._pitch / Math.PI * 180; + } + setPitch(pitch: number) { + const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI; + if (this._pitch === p) return; + this._unmodified = false; + this._pitch = p; + this._calcMatrices(); + } + + get fov(): number { + return this._fov / Math.PI * 180; + } + setFov(fov: number) { + fov = Math.max(0.01, Math.min(60, fov)); + if (this._fov === fov) return; + this._unmodified = false; + this._fov = fov / 180 * Math.PI; + this._calcMatrices(); + } + + get zoom(): number { return this._zoom; } + setZoom(zoom: number) { + const constrainedZoom = this.getConstrained(this._center, zoom).zoom; + if (this._zoom === constrainedZoom) return; + this._unmodified = false; + this._zoom = constrainedZoom; + this._tileZoom = Math.max(0, Math.floor(constrainedZoom)); + this._scale = zoomScale(constrainedZoom); + this._constrain(); + this._calcMatrices(); + } + + get center(): LngLat { return this._center; } + setCenter(center: LngLat) { + if (center.lat === this._center.lat && center.lng === this._center.lng) return; + this._unmodified = false; + this._center = center; + this._constrain(); + this._calcMatrices(); + } + + /** + * Elevation at current center point, meters above sea level + */ + get elevation(): number { return this._elevation; } + setElevation(elevation: number) { + if (elevation === this._elevation) return; + this._elevation = elevation; + this._constrain(); + this._calcMatrices(); + } + + get padding(): PaddingOptions { return this._edgeInsets.toJSON(); } + setPadding(padding: PaddingOptions) { + if (this._edgeInsets.equals(padding)) return; + this._unmodified = false; + // Update edge-insets in-place + this._edgeInsets.interpolate(this._edgeInsets, padding, 1); + this._calcMatrices(); + } + + /** + * The center of the screen in pixels with the top-left corner being (0,0) + * and +y axis pointing downwards. This accounts for padding. + */ + get centerPoint(): Point { + return this._edgeInsets.getCenter(this._width, this._height); + } + + /** + * @internal + */ + get pixelsPerMeter(): number { return this._pixelPerMeter; } + + get unmodified(): boolean { return this._unmodified; } + + /** + * Returns if the padding params match + * + * @param padding - the padding to check against + * @returns true if they are equal, false otherwise + */ + isPaddingEqual(padding: PaddingOptions): boolean { + return this._edgeInsets.equals(padding); + } + + /** + * Helper method to update edge-insets in place + * + * @param start - the starting padding + * @param target - the target padding + * @param t - the step/weight + */ + interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void { + this._unmodified = false; + this._edgeInsets.interpolate(start, target, t); + this._constrain(); + this._calcMatrices(); + } + + /** + * Return what zoom level of a tile source would most closely cover the tiles displayed by this transform. + * @param options - The options, most importantly the source's tile size. + * @returns An integer zoom level at which all tiles will be visible. + */ + coveringZoomLevel(options: CoveringZoomOptions): number { + const z = (options.roundZoom ? Math.round : Math.floor)( + this.zoom + scaleZoom(this._tileSize / options.tileSize) + ); + // At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist. + return Math.max(0, z); + } + + resize(width: number, height: number) { + this._width = width; + this._height = height; + this._constrain(); + this._calcMatrices(); + } + + /** + * Returns the maximum geographical bounds the map is constrained to, or `null` if none set. + * @returns max bounds + */ + getMaxBounds(): LngLatBounds | null { + if (!this._latRange || this._latRange.length !== 2 || + !this._lngRange || this._lngRange.length !== 2) return null; + + return new LngLatBounds([this._lngRange[0], this._latRange[0]], [this._lngRange[1], this._latRange[1]]); + } + + /** + * Sets or clears the map's geographical constraints. + * @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map. + */ + setMaxBounds(bounds?: LngLatBounds | null): void { + if (bounds) { + this._lngRange = [bounds.getWest(), bounds.getEast()]; + this._latRange = [Math.max(bounds.getSouth(), -MAX_VALID_LATITUDE), Math.min(bounds.getNorth(), MAX_VALID_LATITUDE)]; + this._constrain(); + } else { + this._lngRange = null; + this._latRange = [-MAX_VALID_LATITUDE, MAX_VALID_LATITUDE]; + } + } + + private getConstrained(lngLat: LngLat, zoom: number): {center: LngLat; zoom: number} { + return this._callbacks.getConstrained(lngLat, zoom); + } + + /** + * When the map is pitched, some of the 3D features that intersect a query will not intersect + * the query at the surface of the earth. Instead the feature may be closer and only intersect + * the query because it extrudes into the air. + * @param queryGeometry - For point queries, the line from the query point to the "camera point", + * for other geometries, the envelope of the query geometry and the "camera point" + * @returns a geometry that includes all of the original query as well as all possible ares of the + * screen where the *base* of a visible extrusion could be. + * + */ + getCameraQueryGeometry(cameraPoint: Point, queryGeometry: Array): Array { + if (queryGeometry.length === 1) { + return [queryGeometry[0], cameraPoint]; + } else { + let minX = cameraPoint.x; + let minY = cameraPoint.y; + let maxX = cameraPoint.x; + let maxY = cameraPoint.y; + for (const p of queryGeometry) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + return [ + new Point(minX, minY), + new Point(maxX, minY), + new Point(maxX, maxY), + new Point(minX, maxY), + new Point(minX, minY) + ]; + } + } + + /** + * @internal + * Snaps the transform's center, zoom, etc. into the valid range. + */ + private _constrain(): void { + if (!this.center || !this._width || !this._height || this._constraining) return; + this._constraining = true; + const unmodified = this._unmodified; + const {center, zoom} = this.getConstrained(this.center, this.zoom); + this.setCenter(center); + this.setZoom(zoom); + this._unmodified = unmodified; + this._constraining = false; + } + + /** + * This function is called every time one of the transform's defining properties (center, pitch, etc.) changes. + * This function should update the transform's internal data, such as matrices. + * Any derived `_calcMatrices` function should also call the base function first. The base function only depends on the `_width` and `_height` fields. + */ + private _calcMatrices(): void { + if (this._width && this._height) { + this._pixelsToGLUnits = [2 / this._width, -2 / this._height]; + + let m = mat4.identity(new Float64Array(16) as any); + mat4.scale(m, m, [this._width / 2, -this._height / 2, 1]); + mat4.translate(m, m, [1, -1, 0]); + this._clipSpaceToPixelsMatrix = m; + + m = mat4.identity(new Float64Array(16) as any); + mat4.scale(m, m, [1, -1, 1]); + mat4.translate(m, m, [-1, -1, 0]); + mat4.scale(m, m, [2 / this._width, 2 / this._height, 1]); + this._pixelsToClipSpaceMatrix = m; + } + this._callbacks.calcMatrices(); + } +} diff --git a/src/geo/transform_interface.ts b/src/geo/transform_interface.ts new file mode 100644 index 0000000000..f380399fa0 --- /dev/null +++ b/src/geo/transform_interface.ts @@ -0,0 +1,502 @@ +import {LngLat, LngLatLike} from './lng_lat'; +import {LngLatBounds} from './lng_lat_bounds'; +import {MercatorCoordinate} from './mercator_coordinate'; +import Point from '@mapbox/point-geometry'; +import {mat4, mat2, vec3} from 'gl-matrix'; +import {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../source/tile_id'; +import type {PaddingOptions} from './edge_insets'; +import {Terrain} from '../render/terrain'; +import {PointProjection} from '../symbol/projection'; +import {MapProjectionEvent} from '../ui/events'; +import type {ProjectionData} from './projection/projection_data'; + +export type CoveringZoomOptions = { + /** + * Whether to round or floor the target zoom level. If true, the value will be rounded to the closest integer. Otherwise the value will be floored. + */ + roundZoom?: boolean; + /** + * Tile size, expressed in screen pixels. + */ + tileSize: number; +}; + +export type CoveringTilesOptions = CoveringZoomOptions & { + /** + * Smallest allowed tile zoom. + */ + minzoom?: number; + /** + * Largest allowed tile zoom. + */ + maxzoom?: number; + /** + * `true` if tiles should be sent back to the worker for each overzoomed zoom level, `false` if not. + * Fill this option when computing covering tiles for a source. + * When true, any tile at `maxzoom` level that should be overscaled to a greater zoom will have + * its zoom set to the overscaled greater zoom. When false, such tiles will have zoom set to `maxzoom`. + */ + reparseOverscaled?: boolean; + /** + * When terrain is present, tile visibility will be computed in regards to the min and max elevations for each tile. + */ + terrain?: Terrain; +}; + +export type TransformUpdateResult = { + forcePlacementUpdate?: boolean; + fireProjectionEvent?: MapProjectionEvent; + forceSourceUpdate?: boolean; +}; + +export interface ITransformGetters { + get tileSize(): number; + + get tileZoom(): number; + + /** + * How many times "larger" the world is compared to zoom 0. Usually computed as `pow(2, zoom)`. + * Relevant mostly for mercator projection. + */ + get scale(): number; + + /** + * How many units the current world has. Computed by multiplying {@link worldSize} by {@link tileSize}. + * Relevant mostly for mercator projection. + */ + get worldSize(): number; + + /** + * Gets the transform's width in pixels. Use {@link ITransform.resize} to set the transform's size. + */ + get width(): number; + /** + * Gets the transform's height in pixels. Use {@link ITransform.resize} to set the transform's size. + */ + get height(): number; + + /** + * Gets the transform's bearing in radians. + */ + get angle(): number; + + get lngRange(): [number, number]; + get latRange(): [number, number]; + + get minZoom(): number; + get maxZoom(): number; + get zoom(): number; + get center(): LngLat; + + get minPitch(): number; + get maxPitch(): number; + /** + * Pitch in degrees. + */ + get pitch(): number; + /** + * Bearing in degrees. + */ + get bearing(): number; + /** + * Vertical field of view in degrees. + */ + get fov(): number; + + get elevation(): number; + get minElevationForCurrentTile(): number; + + get padding(): PaddingOptions; + get unmodified(): boolean; + + get renderWorldCopies(): boolean; +} + +/** + * @internal + * All the functions that may mutate a transform. + */ +interface ITransformMutators { + clone(): ITransform; + + apply(that: IReadonlyTransform): void; + + /** + * Sets the transform's minimal allowed zoom level. + * Automatically constrains the transform's zoom to the new range and recomputes internal matrices if needed. + */ + setMinZoom(zoom: number): void; + /** + * Sets the transform's maximal allowed zoom level. + * Automatically constrains the transform's zoom to the new range and recomputes internal matrices if needed. + */ + setMaxZoom(zoom: number): void; + /** + * Sets the transform's minimal allowed pitch, in degrees. + * Automatically constrains the transform's pitch to the new range and recomputes internal matrices if needed. + */ + setMinPitch(pitch: number): void; + /** + * Sets the transform's maximal allowed pitch, in degrees. + * Automatically constrains the transform's pitch to the new range and recomputes internal matrices if needed. + */ + setMaxPitch(pitch: number): void; + setRenderWorldCopies(renderWorldCopies: boolean): void; + /** + * Sets the transform's bearing, in degrees. + * Recomputes internal matrices if needed. + */ + setBearing(bearing: number): void; + /** + * Sets the transform's pitch, in degrees. + * Recomputes internal matrices if needed. + */ + setPitch(pitch: number): void; + /** + * Sets the transform's vertical field of view, in degrees. + * Recomputes internal matrices if needed. + */ + setFov(fov: number): void; + /** + * Sets the transform's zoom. + * Automatically constrains the transform's center and zoom and recomputes internal matrices if needed. + */ + setZoom(zoom: number): void; + /** + * Sets the transform's center. + * Automatically constrains the transform's center and zoom and recomputes internal matrices if needed. + */ + setCenter(center: LngLat): void; + setElevation(elevation: number): void; + setMinElevationForCurrentTile(elevation: number): void; + setPadding(padding: PaddingOptions): void; + + /** + * Sets the transform's width and height and recomputes internal matrices. + */ + resize(width: number, height: number): void; + /** + * Helper method to update edge-insets in place + * + * @param start - the starting padding + * @param target - the target padding + * @param t - the step/weight + */ + interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void; + + /** + * This method works in combination with freezeElevation activated. + * freezeElevation is enabled during map-panning because during this the camera should sit in constant height. + * After panning finished, call this method to recalculate the zoom level for the current camera-height in current terrain. + * @param terrain - the terrain + */ + recalculateZoom(terrain: Terrain): void; + + /** + * Set's the transform's center so that the given point on screen is at the given world coordinates. + * @param lnglat - Desired world coordinates of the point. + * @param point - The screen point that should lie at the given coordinates. + */ + setLocationAtPoint(lnglat: LngLat, point: Point): void; + + /** + * Sets or clears the map's geographical constraints. + * @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map. + */ + setMaxBounds(bounds?: LngLatBounds | null): void; + + /** + * @internal + * Signals to the transform that a new frame is starting. + * The transform might update some of its internal variables and animations based on this. + */ + newFrameUpdate(): TransformUpdateResult; + + /** + * @internal + * Called before rendering to allow the transform implementation + * to precompute data needed to render the given tiles. + * Used in mercator transform to precompute tile matrices (posMatrix). + * @param coords - Array of tile IDs that will be rendered. + */ + precacheTiles(coords: Array): void; +} + +/** + * @internal + * A variant of {@link ITransform} without any mutating functions. + * Note that an instance of {@link IReadonlyTransform} may still be mutated + * by code that has a reference to in under the {@link ITransform} type. + */ +export interface IReadonlyTransform extends ITransformGetters { + /** + * 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. + */ + get cameraToCenterDistance(): number; + get modelViewProjectionMatrix(): mat4; + get projectionMatrix(): mat4; + /** + * Inverse of matrix from camera space to clip space. + */ + get inverseProjectionMatrix(): mat4; + get pixelsToClipSpaceMatrix(): mat4; + get clipSpaceToPixelsMatrix(): mat4; + get pixelsToGLUnits(): [number, number]; + get centerOffset(): Point; + /** + * Gets the transform's width and height in pixels (viewport size). Use {@link resize} to set the transform's size. + */ + get size(): Point; + get rotationMatrix(): mat2; + /** + * The center of the screen in pixels with the top-left corner being (0,0) + * and +y axis pointing downwards. This accounts for padding. + */ + get centerPoint(): Point; + /** + * @internal + */ + get pixelsPerMeter(): number; + /** + * @internal + * Returns the camera's position transformed to be in the same space as 3D features under this transform's projection. Mostly used for globe + fill-extrusion. + */ + get cameraPosition(): vec3; + + get nearZ(): number; + get farZ(): number; + + /** + * Returns if the padding params match + * + * @param padding - the padding to check against + * @returns true if they are equal, false otherwise + */ + isPaddingEqual(padding: PaddingOptions): boolean; + + /** + * Return what zoom level of a tile source would most closely cover the tiles displayed by this transform. + * @param options - The options, most importantly the source's tile size. + * @returns An integer zoom level at which all tiles will be visible. + */ + coveringZoomLevel(options: CoveringZoomOptions): number; + + /** + * @internal + * Return any "wrapped" copies of a given tile coordinate that are visible + * in the current view. + */ + getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): Array; + + /** + * Returns a list of tile coordinates that when rendered cover the entire screen at an optimal detail level. + * Tiles are ordered by ascending distance from camera. + * @param options - Additional options - min & max zoom, terrain presence, etc. + * @returns Array of OverscaledTileID. All OverscaledTileID instances are newly created. + */ + coveringTiles(options: CoveringTilesOptions): Array; + + /** + * @internal + * Given a LngLat location, return the screen point that corresponds to it. + * @param lnglat - location + * @param terrain - optional terrain + * @returns screen point + */ + locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point; + + /** + * @internal + * Given a point on screen, return its LngLat location. + * @param p - screen point + * @param terrain - optional terrain + * @returns lnglat location + */ + screenPointToLocation(p: Point, terrain?: Terrain): LngLat; + + /** + * @internal + * Given a point on screen, return its mercator coordinate. + * @param p - the point + * @param terrain - optional terrain + * @returns lnglat + */ + screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate; + + /** + * @internal + * Returns the map's geographical bounds. When the bearing or pitch is non-zero, the visible region is not + * an axis-aligned rectangle, and the result is the smallest bounds that encompasses the visible region. + * @returns Returns a {@link LngLatBounds} object describing the map's geographical bounds. + */ + getBounds(): LngLatBounds; + + /** + * Returns the maximum geographical bounds the map is constrained to, or `null` if none set. + * @returns max bounds + */ + getMaxBounds(): LngLatBounds | null; + + /** + * @internal + * Returns whether the specified screen point lies on the map. + * May return false if, for example, the point is above the map's horizon, or if doesn't lie on the planet's surface if globe is enabled. + * @param p - The point's coordinates. + * @param terrain - Optional terrain. + */ + isPointOnMapSurface(p: Point, terrain?: Terrain): boolean; + + /** + * Get center lngLat and zoom to ensure that longitude and latitude bounds are respected and regions beyond the map bounds are not displayed. + */ + getConstrained(lngLat: LngLat, zoom: number): {center: LngLat; zoom: number}; + + maxPitchScaleFactor(): number; + + /** + * The camera looks at the map from a 3D (lng, lat, altitude) location. Let's use `cameraLocation` + * as the name for the location under the camera and on the surface of the earth (lng, lat, 0). + * `cameraPoint` is the projected position of the `cameraLocation`. + * + * This point is useful to us because only fill-extrusions that are between `cameraPoint` and + * the query point on the surface of the earth can extend and intersect the query. + * + * When the map is not pitched the `cameraPoint` is equivalent to the center of the map because + * the camera is right above the center of the map. + */ + getCameraPoint(): Point; + + /** + * The altitude of the camera above the center of the map in meters. + */ + getCameraAltitude(): number; + + getRayDirectionFromPixel(p: Point): vec3; + + /** + * When the map is pitched, some of the 3D features that intersect a query will not intersect + * the query at the surface of the earth. Instead the feature may be closer and only intersect + * the query because it extrudes into the air. + * @param queryGeometry - For point queries, the line from the query point to the "camera point", + * for other geometries, the envelope of the query geometry and the "camera point" + * @returns a geometry that includes all of the original query as well as all possible ares of the + * screen where the *base* of a visible extrusion could be. + * + */ + getCameraQueryGeometry(queryGeometry: Array): Array; + + /** + * Return the distance to the camera in clip space from a LngLat. + * This can be compared to the value from the depth buffer (terrain.depthAtPoint) + * to determine whether a point is occluded. + * @param lngLat - the point + * @param elevation - the point's elevation + * @returns depth value in clip space (between 0 and 1) + */ + lngLatToCameraDepth(lngLat: LngLat, elevation: number): number; + + /** + * @internal + * Calculate the fogMatrix that, given a tile coordinate, would be used to calculate fog on the map. + * Currently only supported in mercator projection. + * @param unwrappedTileID - the tile ID + */ + calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4; + + /** + * @internal + * True when an animation handled by the transform is in progress, + * requiring MapLibre to keep rendering new frames. + */ + isRenderingDirty(): boolean; + + /** + * @internal + * Generates a `ProjectionData` instance to be used while rendering the supplied tile. + * @param overscaledTileID - The ID of the current tile. + * @param aligned - Set to true if a pixel-aligned matrix should be used, if possible (mostly used for raster tiles under mercator projection). + */ + getProjectionData(overscaledTileID: OverscaledTileID, aligned?: boolean, ignoreTerrainMatrix?: boolean): 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. + */ + isLocationOccluded(lngLat: LngLat): boolean; + + /** + * @internal + * Returns whether the supplied location, defined by in-tile coordinates and tileID, is occluded in this projection. + * For example during globe rendering a location on the backfacing side of the globe is occluded. + */ + tileCoordinatesOccluded(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): boolean; + + /** + * @internal + */ + getPixelScale(): number; + + /** + * @internal + * Allows the projection to adjust the radius of `circle-pitch-alignment: 'map'` circles and heatmap kernels based on the map's latitude. + * Circle radius and heatmap kernel radius is multiplied by this value. + */ + getCircleRadiusCorrection(): number; + + /** + * @internal + * Allows the projection to adjust the scale of `text-pitch-alignment: 'map'` symbols's collision boxes based on the map's center and the text anchor. + * Only affects the collision boxes (and click areas), scaling of the rendered text is mostly handled in shaders. + * @param transform - The map's transform, with only the `center` property, describing the map's longitude and latitude. + * @param textAnchorX - Text anchor position inside the tile, X axis. + * @param textAnchorY - Text anchor position inside the tile, Y axis. + * @param tileID - The tile coordinates. + */ + getPitchedTextCorrection(textAnchorX: number, textAnchorY: number, tileID: UnwrappedTileID): number; + + /** + * @internal + * Returns light direction transformed to be in the same space as 3D features under this projection. Mostly used for globe + fill-extrusion. + * @param transform - Current map transform. + * @param dir - The light direction. + * @returns A new vector with the transformed light direction. + */ + transformLightDirection(dir: vec3): vec3; + + /** + * @internal + * Projects a point in tile coordinates to clip space. Used in symbol rendering. + */ + projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection; + + /** + * Returns a matrix that will place, rotate and scale a model to display at the given location and altitude + * while also being projected by the custom layer matrix. + * This function is intended to be called from custom layers. + * @param location - Location of the model. + * @param altitude - Altitude of the model. May be undefined. + */ + getMatrixForModel(location: LngLatLike, altitude?: number): mat4; + + /** + * Return projection data such that coordinates in mercator projection in range 0..1 will get projected to the map correctly. + */ + getProjectionDataForCustomLayer(): ProjectionData; + + /** + * Returns a tile-specific projection matrix. Used for symbol placement fast-path for mercator transform. + */ + getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 | undefined; +} + +/** + * @internal + * The transform stores everything needed to project or otherwise transform points on a map, + * including most of the map's view state - center, zoom, pitch, etc. + * A transform is cloneable, which is used when a given map state must be retained for multiple frames, mostly during symbol placement. + */ +export interface ITransform extends IReadonlyTransform, ITransformMutators {} + diff --git a/src/gl/cull_face_mode.ts b/src/gl/cull_face_mode.ts index 4bd679a2bf..38693babf0 100644 --- a/src/gl/cull_face_mode.ts +++ b/src/gl/cull_face_mode.ts @@ -1,5 +1,6 @@ import type {CullFaceModeType, FrontFaceType} from './types'; +const FRONT = 0x0404; const BACK = 0x0405; const CCW = 0x0901; @@ -15,8 +16,19 @@ 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; + + /** + * Opposite of {@link backCCW}. Culls front-facing triangles when counterclockwise vertex order is used. + */ + static frontCCW: Readonly; } CullFaceMode.disabled = new CullFaceMode(false, BACK, CCW); CullFaceMode.backCCW = new CullFaceMode(true, BACK, CCW); +CullFaceMode.frontCCW = new CullFaceMode(true, FRONT, CCW); diff --git a/src/gl/render_pool.ts b/src/gl/render_pool.ts index abf1a2c7c8..2b74982f55 100644 --- a/src/gl/render_pool.ts +++ b/src/gl/render_pool.ts @@ -42,6 +42,9 @@ export class RenderPool { const fbo = this._context.createFramebuffer(this._tileSize, this._tileSize, true, true); const texture = new Texture(this._context, {width: this._tileSize, height: this._tileSize, data: null}, this._context.gl.RGBA); texture.bind(this._context.gl.LINEAR, this._context.gl.CLAMP_TO_EDGE); + if (this._context.extTextureFilterAnisotropic) { + this._context.gl.texParameterf(this._context.gl.TEXTURE_2D, this._context.extTextureFilterAnisotropic.TEXTURE_MAX_ANISOTROPY_EXT, this._context.extTextureFilterAnisotropicMax); + } fbo.depthAttachment.set(this._context.createRenderbuffer(this._context.gl.DEPTH_STENCIL, this._tileSize, this._tileSize)); fbo.colorAttachment.set(texture.texture); return {id, fbo, texture, stamp: -1, inUse: false}; diff --git a/src/index.ts b/src/index.ts index 8fc5f549cb..73fcf3b703 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import packageJSON from '../package.json' with {type: 'json'}; +import packageJSON from '../package.json' with { type: 'json' }; import {Map} from './ui/map'; import {NavigationControl} from './ui/control/navigation_control'; import {GeolocateControl} from './ui/control/geolocate_control'; @@ -14,7 +14,7 @@ import {LngLat, LngLatLike} from './geo/lng_lat'; import {LngLatBounds, LngLatBoundsLike} from './geo/lng_lat_bounds'; import Point from '@mapbox/point-geometry'; import {MercatorCoordinate} from './geo/mercator_coordinate'; -import {Evented} from './util/evented'; +import {Evented, Event} from './util/evented'; import {config} from './util/config'; import {rtlMainThreadPluginFactory} from './source/rtl_text_plugin_main_thread'; import {WorkerPool} from './util/worker_pool'; @@ -27,6 +27,8 @@ import {RasterDEMTileSource} from './source/raster_dem_tile_source'; import {RasterTileSource} from './source/raster_tile_source'; import {VectorTileSource} from './source/vector_tile_source'; import {VideoSource} from './source/video_source'; +import {OverscaledTileID} from './source/tile_id'; +import {Tile} from './source/tile'; import {Source, addSourceType} from './source/source'; import {addProtocol, removeProtocol} from './source/protocol_crud'; import {getGlobalDispatcher} from './util/dispatcher'; @@ -46,6 +48,8 @@ import {DoubleClickZoomHandler} from './ui/handler/shim/dblclick_zoom'; import {KeyboardHandler} from './ui/handler/keyboard'; import {TwoFingersTouchPitchHandler, TwoFingersTouchRotateHandler, TwoFingersTouchZoomHandler} from './ui/handler/two_fingers_touch'; import {MessageType} from './util/actor_messages'; +import {createTileMesh} from './util/create_tile_mesh'; +import {SourceCache} from './source/source_cache'; const version = packageJSON.version; export type * from '@maplibre/maplibre-gl-style-spec'; @@ -187,6 +191,9 @@ export { LngLatBounds, Point, MercatorCoordinate, + SourceCache, + Event, + Tile, Evented, AJAXError, config, @@ -212,6 +219,7 @@ export { MapWheelEvent, MapTouchEvent, MapMouseEvent, + OverscaledTileID, type IControl, type CustomLayerInterface, type CanvasSourceSpecification, @@ -246,5 +254,6 @@ export { addProtocol, removeProtocol, addSourceType, - importScriptInWorkers + importScriptInWorkers, + createTileMesh }; diff --git a/src/render/draw_background.ts b/src/render/draw_background.ts index 16ba882cae..1b0182b1af 100644 --- a/src/render/draw_background.ts +++ b/src/render/draw_background.ts @@ -19,6 +19,7 @@ export function drawBackground(painter: Painter, sourceCache: SourceCache, layer const context = painter.context; const gl = context.gl; + const projection = painter.style.projection; const transform = painter.transform; const tileSize = transform.tileSize; const image = layer.paint.get('background-pattern'); @@ -39,15 +40,27 @@ export function drawBackground(painter: Painter, sourceCache: SourceCache, layer } const crossfade = layer.getCrossfadeParameters(); + for (const tileID of tileIDs) { - const matrix = coords ? tileID.posMatrix : painter.transform.calculatePosMatrix(tileID.toUnwrapped()); + const projectionData = transform.getProjectionData(tileID); + const uniformValues = image ? - backgroundPatternUniformValues(matrix, opacity, painter, image, {tileID, tileSize}, crossfade) : - backgroundUniformValues(matrix, opacity, color); + backgroundPatternUniformValues(opacity, painter, image, {tileID, tileSize}, crossfade) : + backgroundUniformValues(opacity, color); 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, - painter.quadTriangleIndexBuffer, painter.tileExtentSegments); + // For globe rendering, background uses tile meshes *without* borders and no stencil clipping. + // This works assuming the tileIDs list contains only tiles of the same zoom level. + // This seems to always be the case for background layers, but I'm leaving this comment + // here in case this assumption is false in the future. + + // In case background starts having tiny holes at tile boundaries, switch to meshes with borders + // and also enable stencil clipping. Make sure to render a proper tile clipping mask into stencil + // first though, as that doesn't seem to happen for background layers as of writing this. + + const mesh = projection.getMeshFromTileID(context, tileID.canonical, false, true, 'raster'); + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.backCCW, + uniformValues, terrainData, projectionData, layer.id, + mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); } } diff --git a/src/render/draw_circle.ts b/src/render/draw_circle.ts index be08b0dd4d..02b91d1129 100644 --- a/src/render/draw_circle.ts +++ b/src/render/draw_circle.ts @@ -16,6 +16,8 @@ import type {IndexBuffer} from '../gl/index_buffer'; import type {UniformValues} from './uniform_binding'; import type {CircleUniformsType} from './program/circle_program'; import type {TerrainData} from '../render/terrain'; +import {translatePosition} from '../util/util'; +import type {ProjectionData} from '../geo/projection/projection_data'; type TileRenderState = { programConfiguration: ProgramConfiguration; @@ -24,6 +26,7 @@ type TileRenderState = { indexBuffer: IndexBuffer; uniformValues: UniformValues; terrainData: TerrainData; + projectionData: ProjectionData; }; type SegmentsTileRenderState = { @@ -46,6 +49,7 @@ export function drawCircles(painter: Painter, sourceCache: SourceCache, layer: C const context = painter.context; const gl = context.gl; + const transform = painter.transform; const depthMode = painter.depthModeForSublayer(0, DepthMode.ReadOnly); // Turn off stencil testing to allow circles to be drawn across boundaries, @@ -55,6 +59,9 @@ export function drawCircles(painter: Painter, sourceCache: SourceCache, layer: C const segmentsRenderStates: Array = []; + // Note: due to how the shader is written, this value only has effect when globe rendering is enabled and `circle-pitch-alignment` is set to 'map'. + const radiusCorrectionFactor = transform.getCircleRadiusCorrection(); + for (let i = 0; i < coords.length; i++) { const coord = coords[i]; @@ -62,12 +69,18 @@ export function drawCircles(painter: Painter, sourceCache: SourceCache, layer: C const bucket: CircleBucket = (tile.getBucket(layer) as any); if (!bucket) continue; + const styleTranslate = layer.paint.get('circle-translate'); + const styleTranslateAnchor = layer.paint.get('circle-translate-anchor'); + const translateForUniforms = translatePosition(transform, tile, styleTranslate, styleTranslateAnchor); + const programConfiguration = bucket.programConfigurations.get(layer.id); const program = painter.useProgram('circle', programConfiguration); const layoutVertexBuffer = bucket.layoutVertexBuffer; const indexBuffer = bucket.indexBuffer; const terrainData = painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord); - const uniformValues = circleUniformValues(painter, coord, tile, layer); + const uniformValues = circleUniformValues(painter, tile, layer, translateForUniforms, radiusCorrectionFactor); + + const projectionData = transform.getProjectionData(coord); const state: TileRenderState = { programConfiguration, @@ -75,7 +88,8 @@ export function drawCircles(painter: Painter, sourceCache: SourceCache, layer: C layoutVertexBuffer, indexBuffer, uniformValues, - terrainData + terrainData, + projectionData }; if (sortFeaturesByKey) { @@ -102,11 +116,11 @@ export function drawCircles(painter: Painter, sourceCache: SourceCache, layer: C } for (const segmentsState of segmentsRenderStates) { - const {programConfiguration, program, layoutVertexBuffer, indexBuffer, uniformValues, terrainData} = segmentsState.state; + const {programConfiguration, program, layoutVertexBuffer, indexBuffer, uniformValues, terrainData, projectionData} = segmentsState.state; const segments = segmentsState.segments; - program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, terrainData, layer.id, + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.backCCW, + uniformValues, terrainData, projectionData, 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 45caece6d4..99f362792b 100644 --- a/src/render/draw_collision_debug.ts +++ b/src/render/draw_collision_debug.ts @@ -10,15 +10,12 @@ import {collisionUniformValues, collisionCircleUniformValues} from './program/co import {QuadTriangleArray, CollisionCircleLayoutArray} from '../data/array_types.g'; import {collisionCircleLayout} from '../data/bucket/symbol_attributes'; import {SegmentVector} from '../data/segment'; -import {mat4} from 'gl-matrix'; import {VertexBuffer} from '../gl/vertex_buffer'; import {IndexBuffer} from '../gl/index_buffer'; type TileBatch = { circleArray: Array; circleOffset: number; - transform: mat4; - invTransform: mat4; coord: OverscaledTileID; }; @@ -26,6 +23,7 @@ let quadTriangles: QuadTriangleArray; export function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, layer: StyleLayer, coords: Array, isText: boolean) { const context = painter.context; + const transform = painter.transform; const gl = context.gl; const program = painter.useProgram('collisionBox'); const tileBatches: Array = []; @@ -36,24 +34,16 @@ export function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, l const coord = coords[i]; const tile = sourceCache.getTile(coord); const bucket: SymbolBucket = (tile.getBucket(layer) as any); - if (!bucket) continue; + if (!bucket) { + continue; + } const buffers = isText ? bucket.textCollisionBox : bucket.iconCollisionBox; // Get collision circle data of this bucket const circleArray: Array = bucket.collisionCircleArray; if (circleArray.length > 0) { - // We need to know the projection matrix that was used for projecting collision circles to the screen. - // This might vary between buckets as the symbol placement is a continuous process. This matrix is - // required for transforming points from previous screen space to the current one - const invTransform = mat4.create(); - - mat4.mul(invTransform, bucket.placementInvProjMatrix, painter.transform.glCoordMatrix); - mat4.mul(invTransform, invTransform, bucket.placementViewportMatrix); - tileBatches.push({ circleArray, circleOffset, - transform: coord.posMatrix, // Ignore translation - invTransform, coord }); @@ -70,8 +60,9 @@ export function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, l DepthMode.disabled, StencilMode.disabled, painter.colorModeForRenderPass(), CullFaceMode.disabled, - collisionUniformValues(painter.transform, coord.posMatrix), + collisionUniformValues(painter.transform), painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord), + transform.getProjectionData(coord), layer.id, buffers.layoutVertexBuffer, buffers.indexBuffer, buffers.segments, null, painter.transform.zoom, null, null, buffers.collisionVertexBuffer); @@ -115,11 +106,7 @@ export function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, l // Render batches for (const batch of tileBatches) { - const uniforms = collisionCircleUniformValues( - batch.transform, - batch.invTransform, - painter.transform - ); + const uniforms = collisionCircleUniformValues(painter.transform); circleProgram.draw( context, @@ -130,6 +117,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_custom.test.ts b/src/render/draw_custom.test.ts index b289ed6222..9e5868ca4c 100644 --- a/src/render/draw_custom.test.ts +++ b/src/render/draw_custom.test.ts @@ -1,12 +1,12 @@ -import {mat4} from 'gl-matrix'; import {OverscaledTileID} from '../source/tile_id'; import {SourceCache} from '../source/source_cache'; import {Tile} from '../source/tile'; import {Painter} from './painter'; import type {Map} from '../ui/map'; -import {Transform} from '../geo/transform'; import {drawCustom} from './draw_custom'; import {CustomStyleLayer} from '../style/style_layer/custom_style_layer'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; +import {MercatorProjection} from '../geo/projection/mercator'; jest.mock('./painter'); jest.mock('./program'); @@ -18,11 +18,14 @@ jest.mock('../symbol/projection'); describe('drawCustom', () => { test('should return custom render method inputs', () => { // same transform setup as in transform.test.ts 'creates a transform', so matrices of transform should be the same - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(500, 500); - transform.minPitch = 10; - transform.maxPitch = 10; + transform.setMinPitch(10); + transform.setMaxPitch(10); const mockPainter = new Painter(null, null); + mockPainter.style = { + projection: new MercatorProjection(), + } as any; mockPainter.renderPass = 'translucent'; mockPainter.transform = transform; mockPainter.context = { @@ -37,13 +40,11 @@ describe('drawCustom', () => { } as any; const tileId = new OverscaledTileID(1, 0, 1, 0, 0); - tileId.posMatrix = mat4.create(); const tile = new Tile(tileId, 256); tile.tileID = tileId; tile.imageAtlasTexture = { bind: () => { } } as any; - // (tile.getBucket as jest.Mock).mockReturnValue(bucketMock); const sourceCacheMock = new SourceCache(null, null, null); (sourceCacheMock.getTile as jest.Mock).mockReturnValue(tile); sourceCacheMock.map = {showCollisionBoxes: false} as any as Map; @@ -52,22 +53,21 @@ describe('drawCustom', () => { const mockLayer = new CustomStyleLayer({ id: 'custom-layer', type: 'custom', - render(gl, matrix, args) { + render(gl, args) { result = { gl, - matrix, args }; }, }); drawCustom(mockPainter, sourceCacheMock, mockLayer); expect(result.gl).toBeDefined(); - expect(result.matrix).toEqual([...mockPainter.transform.mercatorMatrix.values()]); expect(result.args.farZ).toBe(804.8028169246645); expect(result.args.farZ).toBe(mockPainter.transform.farZ); expect(result.args.nearZ).toBe(mockPainter.transform.nearZ); - expect(result.args.fov).toBe(mockPainter.transform._fov); + expect(result.args.fov).toBe(mockPainter.transform.fov * Math.PI / 180); expect(result.args.modelViewProjectionMatrix).toEqual(mockPainter.transform.modelViewProjectionMatrix); expect(result.args.projectionMatrix).toEqual(mockPainter.transform.projectionMatrix); + // JP: TODO: test projection args }); }); diff --git a/src/render/draw_custom.ts b/src/render/draw_custom.ts index ec0f18acff..4cd102a1cb 100644 --- a/src/render/draw_custom.ts +++ b/src/render/draw_custom.ts @@ -3,12 +3,30 @@ import {StencilMode} from '../gl/stencil_mode'; import type {Painter} from './painter'; import type {SourceCache} from '../source/source_cache'; -import type {CustomStyleLayer} from '../style/style_layer/custom_style_layer'; +import type {CustomRenderMethodInput, CustomStyleLayer} from '../style/style_layer/custom_style_layer'; export function drawCustom(painter: Painter, sourceCache: SourceCache, layer: CustomStyleLayer) { const context = painter.context; const implementation = layer.implementation; + const projection = painter.style.projection; + const transform = painter.transform; + + const projectionData = transform.getProjectionDataForCustomLayer(); + + const customLayerArgs: CustomRenderMethodInput = { + farZ: transform.farZ, + nearZ: transform.nearZ, + fov: transform.fov * Math.PI / 180, // fov converted to radians + modelViewProjectionMatrix: transform.modelViewProjectionMatrix, + projectionMatrix: transform.projectionMatrix, + shaderData: { + variantName: projection.shaderVariantName, + vertexShaderPrelude: `const float PI = 3.141592653589793;\nuniform mat4 u_projection_matrix;\n${projection.shaderPreludeCode.vertexSource}`, + define: projection.shaderDefine, + }, + defaultProjectionData: projectionData, + }; if (painter.renderPass === 'offscreen') { @@ -17,7 +35,7 @@ export function drawCustom(painter: Painter, sourceCache: SourceCache, layer: Cu painter.setCustomLayerDefaults(); context.setColorMode(painter.colorModeForRenderPass()); - prerender.call(implementation, context.gl, painter.transform.customLayerMatrix()); + prerender.call(implementation, context.gl, customLayerArgs); context.setDirty(); painter.setBaseState(); @@ -36,7 +54,7 @@ export function drawCustom(painter: Painter, sourceCache: SourceCache, layer: Cu context.setDepthMode(depthMode); - implementation.render(context.gl, painter.transform.customLayerMatrix(), {farZ: painter.transform.farZ, nearZ: painter.transform.nearZ, fov: painter.transform._fov, modelViewProjectionMatrix: painter.transform.modelViewProjectionMatrix, projectionMatrix: painter.transform.projectionMatrix}); + implementation.render(context.gl, customLayerArgs); context.setDirty(); painter.setBaseState(); diff --git a/src/render/draw_debug.ts b/src/render/draw_debug.ts index 5b72587b9b..80551e241d 100644 --- a/src/render/draw_debug.ts +++ b/src/render/draw_debug.ts @@ -69,7 +69,6 @@ function drawDebugTile(painter: Painter, sourceCache: SourceCache, coord: Oversc const context = painter.context; const gl = context.gl; - const posMatrix = coord.posMatrix; const program = painter.useProgram('debug'); const depthMode = DepthMode.disabled; @@ -92,11 +91,13 @@ function drawDebugTile(painter: Painter, sourceCache: SourceCache, coord: Oversc const tileLabel = `${tileIdText} ${tileSizeKb}kB`; drawTextToOverlay(painter, tileLabel); + const projectionData = painter.transform.getProjectionData(coord); + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, ColorMode.alphaBlended, CullFaceMode.disabled, - debugUniformValues(posMatrix, Color.transparent, scaleRatio), null, id, + debugUniformValues(Color.transparent, scaleRatio), null, projectionData, 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(Color.red), terrainData, projectionData, id, painter.debugBuffer, painter.tileBorderIndexBuffer, painter.debugSegments); } diff --git a/src/render/draw_fill.test.ts b/src/render/draw_fill.test.ts index 969ba9b675..aef5b2e811 100644 --- a/src/render/draw_fill.test.ts +++ b/src/render/draw_fill.test.ts @@ -6,7 +6,7 @@ import {Painter} from './painter'; import {Program} from './program'; import type {ZoomHistory} from '../style/zoom_history'; import type {Map} from '../ui/map'; -import {Transform} from '../geo/transform'; +import {IReadonlyTransform} from '../geo/transform_interface'; import type {EvaluationParameters} from '../style/evaluation_parameters'; import type {FillLayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import {Style} from '../style/style'; @@ -14,6 +14,7 @@ import {FillStyleLayer} from '../style/style_layer/fill_style_layer'; import {drawFill} from './draw_fill'; import {FillBucket} from '../data/bucket/fill_bucket'; import {ProgramConfiguration, ProgramConfigurationSet} from '../data/program_configuration'; +import type {ProjectionData} from '../geo/projection/projection_data'; jest.mock('./painter'); jest.mock('./program'); @@ -28,7 +29,7 @@ describe('drawFill', () => { 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); @@ -82,10 +83,26 @@ describe('drawFill', () => { } } as any; painterMock.renderPass = 'translucent'; - painterMock.transform = {pitch: 0, labelPlaneMatrix: mat4.create()} as any as Transform; + painterMock.transform = { + pitch: 0, + labelPlaneMatrix: mat4.create(), + zoom: 0, + angle: 0, + getProjectionData(_canonical, fallback): ProjectionData { + return { + mainMatrix: fallback, + tileMercatorCoords: [0, 0, 1, 1], + clippingPlane: [0, 0, 0, 0], + projectionTransition: 0.0, + fallbackMatrix: fallback, + }; + }, + } as any as IReadonlyTransform; painterMock.options = {} as any; painterMock.style = { - map: {} + map: { + projection: {} + } } as any as Style; return painterMock; @@ -93,7 +110,7 @@ describe('drawFill', () => { function constructMockTile(layer: FillStyleLayer): Tile { const tileId = new OverscaledTileID(1, 0, 1, 0, 0); - tileId.posMatrix = mat4.create(); + tileId.terrainRttPosMatrix = mat4.create(); const tile = new Tile(tileId, 256); tile.tileID = tileId; diff --git a/src/render/draw_fill.ts b/src/render/draw_fill.ts index c850dc9882..05edc7506d 100644 --- a/src/render/draw_fill.ts +++ b/src/render/draw_fill.ts @@ -15,6 +15,8 @@ import type {FillStyleLayer} from '../style/style_layer/fill_style_layer'; import type {FillBucket} from '../data/bucket/fill_bucket'; import type {OverscaledTileID} from '../source/tile_id'; import {updatePatternPositionsInProgram} from './update_pattern_positions_in_program'; +import {StencilMode} from '../gl/stencil_mode'; +import {translatePosition} from '../util/util'; export function drawFill(painter: Painter, sourceCache: SourceCache, layer: FillStyleLayer, coords: Array) { const color = layer.paint.get('fill-color'); @@ -71,6 +73,11 @@ function drawFillTiles( const crossfade = layer.getCrossfadeParameters(); let drawMode, programName, uniformValues, indexBuffer, segments; + const transform = painter.transform; + + const propertyFillTranslate = layer.paint.get('fill-translate'); + const propertyFillTranslateAnchor = layer.paint.get('fill-translate-anchor'); + if (!isOutline) { programName = image ? 'fillPattern' : 'fill'; drawMode = gl.TRIANGLES; @@ -100,28 +107,47 @@ function drawFillTiles( updatePatternPositionsInProgram(programConfiguration, fillPropertyName, constantPattern, tile, layer); - const terrainCoord = terrainData ? coord : null; - const posMatrix = terrainCoord ? terrainCoord.posMatrix : coord.posMatrix; - const tileMatrix = painter.translatePosMatrix(posMatrix, tile, - layer.paint.get('fill-translate'), layer.paint.get('fill-translate-anchor')); + const projectionData = transform.getProjectionData(coord); + + const translateForUniforms = translatePosition(transform, tile, propertyFillTranslate, propertyFillTranslateAnchor); if (!isOutline) { indexBuffer = bucket.indexBuffer; segments = bucket.segments; - uniformValues = image ? - fillPatternUniformValues(tileMatrix, painter, crossfade, tile) : - fillUniformValues(tileMatrix); + uniformValues = image ? fillPatternUniformValues(painter, crossfade, tile, translateForUniforms) : fillUniformValues(translateForUniforms); } else { indexBuffer = bucket.indexBuffer2; segments = bucket.segments2; const drawingBufferSize = [gl.drawingBufferWidth, gl.drawingBufferHeight] as [number, number]; uniformValues = (programName === 'fillOutlinePattern' && image) ? - fillOutlinePatternUniformValues(tileMatrix, painter, crossfade, tile, drawingBufferSize) : - fillOutlineUniformValues(tileMatrix, drawingBufferSize); + fillOutlinePatternUniformValues(painter, crossfade, tile, drawingBufferSize, translateForUniforms) : + fillOutlineUniformValues(drawingBufferSize, translateForUniforms); } + // Stencil is not really needed for anything unless we are drawing transparent things. + // + // For translucent layers, we must draw any pixel of a given layer at most once, + // otherwise we might get artifacts from the transparent geometry being drawn twice over itself, + // which can happen due to tiles having a slight overlapping border into neighboring tiles. + // Hence we use stencil tile masks for any translucent pass, including for fill. + // + // Globe rendering relies on these tile borders to hide tile seams, since under globe projection + // tiles are not squares, but slightly curved squares. At high zoom levels, the tile stencil mask + // is approximated by a square, but if the tile contains fine geometry, it might still get projected + // into a curved shape, causing a mismatch with the stencil mask, which is very visible + // if the tile border is small. + // + // The simples workaround for this is to just disable stencil masking for opaque fill layers, + // since the fine geometry will always line up perfectly with the geometry in its neighboring tiles, + // even if the border is small. Disabling stencil ensures the neighboring geometry isn't clipped. + // + // This doesn't seem to be an issue for transparent fill layers (or they don't get used enough to be noticeable), + // which is a good thing, since there is no easy solution for this problem for transparency, other than + // greatly increasing subdivision granularity for both fill layers and stencil masks, at least at tile edges. + const stencil = (painter.renderPass === 'translucent') ? painter.stencilModeForClipping(coord) : StencilMode.disabled; + program.draw(painter.context, drawMode, depthMode, - painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, terrainData, + stencil, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, projectionData, 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..0b2a4b8448 100644 --- a/src/render/draw_fill_extrusion.ts +++ b/src/render/draw_fill_extrusion.ts @@ -14,6 +14,7 @@ import type {FillExtrusionBucket} from '../data/bucket/fill_extrusion_bucket'; import type {OverscaledTileID} from '../source/tile_id'; import {updatePatternPositionsInProgram} from './update_pattern_positions_in_program'; +import {translatePosition} from '../util/util'; export function drawFillExtrusion(painter: Painter, source: SourceCache, layer: FillExtrusionStyleLayer, coords: Array) { const opacity = layer.paint.get('fill-extrusion-opacity'); @@ -61,6 +62,9 @@ function drawExtrusionTiles( const crossfade = layer.getCrossfadeParameters(); const opacity = layer.paint.get('fill-extrusion-opacity'); const constantPattern = patternProperty.constantOr(null); + const transform = painter.transform; + const globeCameraPosition = transform.cameraPosition; + for (const coord of coords) { const tile = source.getTile(coord); const bucket: FillExtrusionBucket = (tile.getBucket(layer) as any); @@ -76,21 +80,23 @@ function drawExtrusionTiles( programConfiguration.updatePaintBuffers(crossfade); } + const projectionData = transform.getProjectionData(coord); updatePatternPositionsInProgram(programConfiguration, fillPropertyName, constantPattern, tile, layer); - const matrix = painter.translatePosMatrix( - coord.posMatrix, + const translate = translatePosition( + transform, tile, layer.paint.get('fill-extrusion-translate'), - layer.paint.get('fill-extrusion-translate-anchor')); + layer.paint.get('fill-extrusion-translate-anchor') + ); const shouldUseVerticalGradient = layer.paint.get('fill-extrusion-vertical-gradient'); const uniformValues = image ? - fillExtrusionPatternUniformValues(matrix, painter, shouldUseVerticalGradient, opacity, coord, crossfade, tile) : - fillExtrusionUniformValues(matrix, painter, shouldUseVerticalGradient, opacity); + fillExtrusionPatternUniformValues(painter, shouldUseVerticalGradient, opacity, translate, globeCameraPosition, coord, crossfade, tile) : + fillExtrusionUniformValues(painter, shouldUseVerticalGradient, opacity, translate, globeCameraPosition); program.draw(context, context.gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.backCCW, - uniformValues, terrainData, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, + uniformValues, terrainData, projectionData, 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 7d37277b62..c28b103a90 100644 --- a/src/render/draw_heatmap.ts +++ b/src/render/draw_heatmap.ts @@ -52,6 +52,7 @@ export function drawHeatmap(painter: Painter, sourceCache: SourceCache, layer: H function prepareHeatmapFlat(painter: Painter, sourceCache: SourceCache, layer: HeatmapStyleLayer, coords: Array) { const context = painter.context; const gl = context.gl; + const transform = painter.transform; // Allow kernels to be drawn across boundaries, so that // large kernels are not clipped to tiles @@ -77,12 +78,16 @@ function prepareHeatmapFlat(painter: Painter, sourceCache: SourceCache, layer: H const programConfiguration = bucket.programConfigurations.get(layer.id); const program = painter.useProgram('heatmap', programConfiguration); - 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, + const projectionData = transform.getProjectionData(coord); + + const radiusCorrectionFactor = transform.getCircleRadiusCorrection(); + + program.draw(context, gl.TRIANGLES, DepthMode.disabled, stencilMode, colorMode, CullFaceMode.backCCW, + heatmapUniformValues(tile, transform.zoom, layer.paint.get('heatmap-intensity'), radiusCorrectionFactor), + null, projectionData, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, - bucket.segments, layer.paint, painter.transform.zoom, + bucket.segments, layer.paint, transform.zoom, programConfiguration); } @@ -109,7 +114,7 @@ function renderHeatmapFlat(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); } @@ -138,11 +143,13 @@ function prepareHeatmapTerrain(painter: Painter, tile: Tile, layer: HeatmapStyle context.clear({color: Color.transparent}); const programConfiguration = bucket.programConfigurations.get(layer.id); - const program = painter.useProgram('heatmap', programConfiguration); + const program = painter.useProgram('heatmap', programConfiguration, true); + + const projectionData = painter.transform.getProjectionData(tile.tileID); const terrainData = painter.style.map.terrain.getTerrainData(coord); program.draw(context, gl.TRIANGLES, DepthMode.disabled, stencilMode, colorMode, CullFaceMode.disabled, - heatmapUniformValues(coord.posMatrix, tile, painter.transform.zoom, layer.paint.get('heatmap-intensity')), terrainData, + heatmapUniformValues(tile, painter.transform.zoom, layer.paint.get('heatmap-intensity'), 1.0), terrainData, projectionData, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, bucket.segments, layer.paint, painter.transform.zoom, programConfiguration); @@ -151,6 +158,7 @@ function prepareHeatmapTerrain(painter: Painter, tile: Tile, layer: HeatmapStyle function renderHeatmapTerrain(painter: Painter, layer: HeatmapStyleLayer, coord: OverscaledTileID) { const context = painter.context; const gl = context.gl; + const transform = painter.transform; context.setColorMode(painter.colorModeForRenderPass()); @@ -169,11 +177,13 @@ function renderHeatmapTerrain(painter: Painter, layer: HeatmapStyleLayer, coord: context.activeTexture.set(gl.TEXTURE1); colorRampTexture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); + const projectionData = transform.getProjectionData(coord, false, true); + 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, projectionData, layer.id, painter.rasterBoundsBuffer, painter.quadTriangleIndexBuffer, - painter.rasterBoundsSegments, layer.paint, painter.transform.zoom); + painter.rasterBoundsSegments, layer.paint, transform.zoom); // destroy the FBO after rendering fbo.destroy(); diff --git a/src/render/draw_hillshade.ts b/src/render/draw_hillshade.ts index 39f464125b..1b6412ca03 100644 --- a/src/render/draw_hillshade.ts +++ b/src/render/draw_hillshade.ts @@ -3,7 +3,6 @@ import {StencilMode} from '../gl/stencil_mode'; import {DepthMode} from '../gl/depth_mode'; import {CullFaceMode} from '../gl/cull_face_mode'; import {ColorMode} from '../gl/color_mode'; -import {Tile} from '../source/tile'; import { hillshadeUniformValues, hillshadeUniformPrepareValues @@ -18,64 +17,95 @@ export function drawHillshade(painter: Painter, sourceCache: SourceCache, layer: if (painter.renderPass !== 'offscreen' && painter.renderPass !== 'translucent') return; const context = painter.context; + const projection = painter.style.projection; + const useSubdivision = projection.useSubdivision; const depthMode = painter.depthModeForSublayer(0, DepthMode.ReadOnly); const colorMode = painter.colorModeForRenderPass(); - const [stencilModes, coords] = painter.renderPass === 'translucent' ? - painter.stencilConfigForOverlap(tileIDs) : [{}, tileIDs]; - - for (const coord of coords) { - const tile = sourceCache.getTile(coord); - if (typeof tile.needsHillshadePrepare !== 'undefined' && tile.needsHillshadePrepare && painter.renderPass === 'offscreen') { - prepareHillshade(painter, tile, layer, depthMode, StencilMode.disabled, colorMode); - } else if (painter.renderPass === 'translucent') { - renderHillshade(painter, coord, tile, layer, depthMode, stencilModes[coord.overscaledZ], colorMode); + if (painter.renderPass === 'offscreen') { + // Prepare tiles + prepareHillshade(painter, sourceCache, tileIDs, layer, depthMode, StencilMode.disabled, colorMode); + context.viewport.set([0, 0, painter.width, painter.height]); + } else if (painter.renderPass === 'translucent') { + // Globe (or any projection with subdivision) needs two-pass rendering to avoid artifacts when rendering texture tiles. + // See comments in draw_raster.ts for more details. + if (useSubdivision) { + // Two-pass rendering + const [stencilBorderless, stencilBorders, coords] = painter.stencilConfigForOverlapTwoPass(tileIDs); + renderHillshade(painter, sourceCache, layer, coords, stencilBorderless, depthMode, colorMode, false); // draw without borders + renderHillshade(painter, sourceCache, layer, coords, stencilBorders, depthMode, colorMode, true); // draw with borders + } else { + // Simple rendering + const [stencil, coords] = painter.stencilConfigForOverlap(tileIDs); + renderHillshade(painter, sourceCache, layer, coords, stencil, depthMode, colorMode, false); } } - - context.viewport.set([0, 0, painter.width, painter.height]); } function renderHillshade( painter: Painter, - coord: OverscaledTileID, - tile: Tile, + sourceCache: SourceCache, layer: HillshadeStyleLayer, + coords: Array, + stencilModes: {[_: number]: Readonly}, depthMode: Readonly, - stencilMode: Readonly, - colorMode: Readonly) { + colorMode: Readonly, + useBorder: boolean +) { + const projection = painter.style.projection; const context = painter.context; + const transform = painter.transform; const gl = context.gl; - const fbo = tile.fbo; - if (!fbo) return; - const program = painter.useProgram('hillshade'); - const terrainData = painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord); + const align = !painter.options.moving; + + for (const coord of coords) { + const tile = sourceCache.getTile(coord); + const fbo = tile.fbo; + if (!fbo) { + continue; + } + const mesh = projection.getMeshFromTileID(context, coord.canonical, useBorder, true, 'raster'); + + const terrainData = painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord); - context.activeTexture.set(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, fbo.colorAttachment.get()); + context.activeTexture.set(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, fbo.colorAttachment.get()); - 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, - painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); + const projectionData = transform.getProjectionData(coord, align); + program.draw(context, gl.TRIANGLES, depthMode, stencilModes[coord.overscaledZ], colorMode, CullFaceMode.backCCW, + hillshadeUniformValues(painter, tile, layer), terrainData, projectionData, layer.id, mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + } } // hillshade rendering is done in two steps. the prepare step first calculates the slope of the terrain in the x and y // directions for each pixel, and saves those values to a framebuffer texture in the r and g channels. function prepareHillshade( painter: Painter, - tile: Tile, + sourceCache: SourceCache, + tileIDs: Array, layer: HillshadeStyleLayer, depthMode: Readonly, stencilMode: Readonly, colorMode: Readonly) { + const context = painter.context; const gl = context.gl; - const dem = tile.dem; - if (dem && dem.data) { + + for (const coord of tileIDs) { + const tile = sourceCache.getTile(coord); + const dem = tile.dem; + + if (!dem || !dem.data) { + continue; + } + + if (!tile.needsHillshadePrepare) { + continue; + } + const tileSize = dem.dim; const textureStride = dem.stride; @@ -111,7 +141,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..407812e3a5 100644 --- a/src/render/draw_line.ts +++ b/src/render/draw_line.ts @@ -41,6 +41,7 @@ export function drawLine(painter: Painter, sourceCache: SourceCache, layer: Line const context = painter.context; const gl = context.gl; + const transform = painter.transform; let firstTile = true; @@ -66,11 +67,13 @@ export function drawLine(painter: Painter, sourceCache: SourceCache, layer: Line if (posTo && posFrom) programConfiguration.setConstantPatternPositions(posTo, posFrom); } - const terrainCoord = terrainData ? coord : null; - const uniformValues = image ? linePatternUniformValues(painter, tile, layer, crossfade, terrainCoord) : - dasharray ? lineSDFUniformValues(painter, tile, layer, dasharray, crossfade, terrainCoord) : - gradient ? lineGradientUniformValues(painter, tile, layer, bucket.lineClipsArray.length, terrainCoord) : - lineUniformValues(painter, tile, layer, terrainCoord); + const projectionData = transform.getProjectionData(coord); + const pixelRatio = transform.getPixelScale(); + + const uniformValues = image ? linePatternUniformValues(painter, tile, layer, pixelRatio, crossfade) : + dasharray ? lineSDFUniformValues(painter, tile, layer, pixelRatio, dasharray, crossfade) : + gradient ? lineGradientUniformValues(painter, tile, layer, pixelRatio, bucket.lineClipsArray.length) : + lineUniformValues(painter, tile, layer, pixelRatio); if (image) { context.activeTexture.set(gl.TEXTURE0); @@ -115,7 +118,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, projectionData, 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 28a3aa2d6f..d505935b1e 100644 --- a/src/render/draw_raster.ts +++ b/src/render/draw_raster.ts @@ -11,25 +11,74 @@ 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.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 - no stencil is used + drawTiles(painter, sourceCache, layer, tileIDs, null, false, false, source.tileCoords, source.flippedWindingOrder); + } else if (useSubdivision) { + // Two-pass rendering + const [stencilBorderless, stencilBorders, coords] = painter.stencilConfigForOverlapTwoPass(tileIDs); + drawTiles(painter, sourceCache, layer, coords, stencilBorderless, false, true, cornerCoords); // draw without borders + drawTiles(painter, sourceCache, layer, coords, stencilBorders, true, true, cornerCoords); // draw with borders + } else { + // Simple rendering + const [stencil, coords] = painter.stencilConfigForOverlap(tileIDs); + drawTiles(painter, sourceCache, layer, coords, stencil, false, true, cornerCoords); + } +} +function drawTiles( + painter: Painter, + sourceCache: SourceCache, + layer: RasterStyleLayer, + coords: Array, + stencilModes: {[_: number]: Readonly} | null, + useBorder: boolean, + allowPoles: boolean, + corners: Array, + flipCullfaceMode: boolean = false) { const minTileZ = coords[coords.length - 1].overscaledZ; + const context = painter.context; + const gl = context.gl; + const program = painter.useProgram('raster'); + const transform = painter.transform; + + const projection = painter.style.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. @@ -59,7 +108,6 @@ 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); } @@ -72,19 +120,16 @@ export function drawRaster(painter: Painter, sourceCache: SourceCache, layer: Ra } 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); - - 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 projectionData = transform.getProjectionData(coord, align); + const uniformValues = rasterUniformValues(parentTL || [0, 0], parentScaleBy || 1, fade, layer, corners); + + const mesh = projection.getMeshFromTileID(context, coord.canonical, useBorder, allowPoles, 'raster'); + + const stencilMode = stencilModes ? stencilModes[coord.overscaledZ] : StencilMode.disabled; + + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, flipCullfaceMode ? CullFaceMode.frontCCW : CullFaceMode.backCCW, + uniformValues, terrainData, projectionData, layer.id, mesh.vertexBuffer, + mesh.indexBuffer, mesh.segments); } } diff --git a/src/render/draw_sky.ts b/src/render/draw_sky.ts index 0ccfa8fbd9..590484ce07 100644 --- a/src/render/draw_sky.ts +++ b/src/render/draw_sky.ts @@ -5,21 +5,19 @@ import {PosArray, TriangleIndexArray} from '../data/array_types.g'; import posAttributes from '../data/pos_attributes'; import {SegmentVector} from '../data/segment'; import {skyUniformValues} from './program/sky_program'; +import {atmosphereUniformValues} from './program/atmosphere_program'; import {Sky} from '../style/sky'; +import {Light} from '../style/light'; import {Mesh} from './mesh'; +import {mat4, vec3, vec4} from 'gl-matrix'; +import {IReadonlyTransform} from '../geo/transform_interface'; +import {ColorMode} from '../gl/color_mode'; import type {Painter} from './painter'; +import {Context} from '../gl/context'; +import {getGlobeRadiusPixels} from '../geo/projection/globe_utils'; -export function drawSky(painter: Painter, sky: Sky) { - const context = painter.context; - const gl = context.gl; - - const skyUniforms = skyUniformValues(sky, painter.style.map.transform, painter.pixelRatio); - - const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, [0, 1]); - const stencilMode = StencilMode.disabled; - const colorMode = painter.colorModeForRenderPass(); - const program = painter.useProgram('sky'); - +function getMesh(context: Context, sky: Sky): Mesh { + // Create the Sky mesh the first time we need it if (!sky.mesh) { const vertexArray = new PosArray(); vertexArray.emplaceBack(-1, -1); @@ -38,7 +36,81 @@ export function drawSky(painter: Painter, sky: Sky) { ); } + return sky.mesh; +} + +export function drawSky(painter: Painter, sky: Sky) { + const context = painter.context; + const gl = context.gl; + + const skyUniforms = skyUniformValues(sky, painter.style.map.transform, painter.pixelRatio); + + const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, [0, 1]); + const stencilMode = StencilMode.disabled; + const colorMode = painter.colorModeForRenderPass(); + const program = painter.useProgram('sky'); + + const mesh = getMesh(context, sky); + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, - CullFaceMode.disabled, skyUniforms, undefined, 'sky', sky.mesh.vertexBuffer, - sky.mesh.indexBuffer, sky.mesh.segments); + CullFaceMode.disabled, skyUniforms, null, undefined, 'sky', mesh.vertexBuffer, + mesh.indexBuffer, mesh.segments); +} + +function getSunPos(light: Light, transform: IReadonlyTransform): vec3 { + const _lp = light.properties.get('position'); + const lightPos = [-_lp.x, -_lp.y, -_lp.z] as vec3; + + const lightMat = mat4.identity(new Float64Array(16) as any); + + if (light.properties.get('anchor') === 'map') { + mat4.rotateX(lightMat, lightMat, -transform.pitch * Math.PI / 180); + mat4.rotateZ(lightMat, lightMat, -transform.angle); + mat4.rotateX(lightMat, lightMat, transform.center.lat * Math.PI / 180.0); + mat4.rotateY(lightMat, lightMat, -transform.center.lng * Math.PI / 180.0); + } + + vec3.transformMat4(lightPos, lightPos, lightMat); + + return lightPos; +} + +export function drawAtmosphere(painter: Painter, sky: Sky, light: Light) { + const context = painter.context; + const gl = context.gl; + const program = painter.useProgram('atmosphere'); + const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadOnly, [0, 1]); + const transform = painter.transform; + + const sunPos = getSunPos(light, painter.transform); + + const projectionData = transform.getProjectionData(null); + const atmosphereBlend = sky.properties.get('atmosphere-blend') * projectionData.projectionTransition; + + if (atmosphereBlend === 0) { + // Don't draw anything if atmosphere is fully transparent + return; + } + + const globeRadius = getGlobeRadiusPixels(transform.worldSize, transform.center.lat); + const invProjMatrix = transform.inverseProjectionMatrix; + const vec = new Float64Array(4) as any as vec4; + vec[3] = 1; + vec4.transformMat4(vec, vec, transform.modelViewProjectionMatrix); + vec[0] /= vec[3]; + vec[1] /= vec[3]; + vec[2] /= vec[3]; + vec[3] = 1; + vec4.transformMat4(vec, vec, invProjMatrix); + vec[0] /= vec[3]; + vec[1] /= vec[3]; + vec[2] /= vec[3]; + vec[3] = 1; + const globePosition = [vec[0], vec[1], vec[2]] as vec3; + + const uniformValues = atmosphereUniformValues(sunPos, atmosphereBlend, globePosition, globeRadius, invProjMatrix); + + const mesh = getMesh(context, sky); + + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, ColorMode.alphaBlended, CullFaceMode.disabled, uniformValues, null, null, 'atmosphere', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); } diff --git a/src/render/draw_symbol.test.ts b/src/render/draw_symbol.test.ts index 5f8de55459..734edc3e3f 100644 --- a/src/render/draw_symbol.test.ts +++ b/src/render/draw_symbol.test.ts @@ -10,10 +10,12 @@ import {drawSymbols} from './draw_symbol'; import * as symbolProjection from '../symbol/projection'; import type {ZoomHistory} from '../style/zoom_history'; import type {Map} from '../ui/map'; -import {Transform} from '../geo/transform'; +import {IReadonlyTransform} from '../geo/transform_interface'; import type {EvaluationParameters} from '../style/evaluation_parameters'; import type {SymbolLayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import {Style} from '../style/style'; +import {MercatorProjection} from '../geo/projection/mercator'; +import type {ProjectionData} from '../geo/projection/projection_data'; jest.mock('./painter'); jest.mock('./program'); @@ -21,6 +23,26 @@ jest.mock('../source/source_cache'); jest.mock('../source/tile'); jest.mock('../data/bucket/symbol_bucket'); jest.mock('../symbol/projection'); +(symbolProjection.getPitchedLabelPlaneMatrix as jest.Mock).mockReturnValue(mat4.create()); + +function createMockTransform() { + return { + pitch: 0, + labelPlaneMatrix: mat4.create(), + getCircleRadiusCorrection: () => 1, + angle: 0, + zoom: 0, + getProjectionData(_canonical, fallback): ProjectionData { + return { + mainMatrix: fallback, + tileMercatorCoords: [0, 0, 1, 1], + clippingPlane: [0, 0, 0, 0], + projectionTransition: 0.0, + fallbackMatrix: fallback, + }; + }, + } as any as IReadonlyTransform; +} describe('drawSymbol', () => { test('should not do anything', () => { @@ -33,7 +55,6 @@ describe('drawSymbol', () => { }); test('should call program.draw', () => { - const painterMock = new Painter(null, null); painterMock.context = { gl: {}, @@ -42,10 +63,11 @@ describe('drawSymbol', () => { } } as any; painterMock.renderPass = 'translucent'; - painterMock.transform = {pitch: 0, labelPlaneMatrix: mat4.create()} as any as Transform; + painterMock.transform = createMockTransform(); painterMock.options = {} as any; painterMock.style = { - map: {} + map: {}, + projection: new MercatorProjection() } as any as Style; const layerSpec = { @@ -61,8 +83,8 @@ describe('drawSymbol', () => { layer.recalculate({zoom: 0, zoomHistory: {} as ZoomHistory} as EvaluationParameters, []); const tileId = new OverscaledTileID(1, 0, 1, 0, 0); - tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + tileId.terrainRttPosMatrix = mat4.create(); + 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 = { @@ -79,14 +101,14 @@ describe('drawSymbol', () => { layoutSize: 1 }; const tile = new Tile(tileId, 256); - tile.tileID = tileId; tile.imageAtlasTexture = { bind: () => { } } as any; - (tile.getBucket as jest.Mock).mockReturnValue(bucketMock); + tile.getBucket = () => bucketMock; + tile.tileID = tileId; const sourceCacheMock = new SourceCache(null, null, null); - (sourceCacheMock.getTile as jest.Mock).mockReturnValue(tile); sourceCacheMock.map = {showCollisionBoxes: false} as any as Map; + sourceCacheMock.getTile = (_a) => tile; drawSymbols(painterMock, sourceCacheMock, layer, [tileId], null); @@ -103,7 +125,7 @@ describe('drawSymbol', () => { } } as any; painterMock.renderPass = 'translucent'; - painterMock.transform = {pitch: 0, labelPlaneMatrix: mat4.create()} as any as Transform; + painterMock.transform = createMockTransform(); painterMock.options = {} as any; const layerSpec = { @@ -123,8 +145,8 @@ describe('drawSymbol', () => { layer.recalculate({zoom: 0, zoomHistory: {} as ZoomHistory} as EvaluationParameters, []); const tileId = new OverscaledTileID(1, 0, 1, 0, 0); - tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + tileId.terrainRttPosMatrix = mat4.create(); + 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 = { @@ -150,13 +172,14 @@ describe('drawSymbol', () => { (sourceCacheMock.getTile as jest.Mock).mockReturnValue(tile); sourceCacheMock.map = {showCollisionBoxes: false} as any as Map; painterMock.style = { - map: {} + map: {}, + projection: new MercatorProjection() } as any as Style; const spy = jest.spyOn(symbolProjection, 'updateLineLabels'); drawSymbols(painterMock, sourceCacheMock, layer, [tileId], null); - expect(spy.mock.calls[0][8]).toBeFalsy(); // rotateToLine === false + expect(spy.mock.calls[0][7]).toBeFalsy(); // rotateToLine === false }); test('transparent tile optimization should prevent program.draw from being called', () => { @@ -169,10 +192,10 @@ describe('drawSymbol', () => { } } as any; painterMock.renderPass = 'translucent'; - painterMock.transform = {pitch: 0, labelPlaneMatrix: mat4.create()} as any as Transform; + painterMock.transform = createMockTransform(); painterMock.options = {} as any; painterMock.style = { - map: {} + projection: new MercatorProjection() } as any as Style; const layerSpec = { @@ -188,8 +211,8 @@ describe('drawSymbol', () => { layer.recalculate({zoom: 0, zoomHistory: {} as ZoomHistory} as EvaluationParameters, []); const tileId = new OverscaledTileID(1, 0, 1, 0, 0); - tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + tileId.terrainRttPosMatrix = mat4.create(); + 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 = { @@ -219,5 +242,4 @@ describe('drawSymbol', () => { expect(programMock.draw).toHaveBeenCalledTimes(0); }); - }); diff --git a/src/render/draw_symbol.ts b/src/render/draw_symbol.ts index dd59f5944d..0aa9db876f 100644 --- a/src/render/draw_symbol.ts +++ b/src/render/draw_symbol.ts @@ -3,7 +3,6 @@ import {drawCollisionDebug} from './draw_collision_debug'; import {SegmentVector} from '../data/segment'; import {pixelsToTileUnits} from '../source/pixels_to_tile_units'; -import * as symbolProjection from '../symbol/projection'; import {EvaluatedZoomSize, evaluateSizeForFeature, evaluateSizeForZoom} from '../symbol/symbol_size'; import {mat4} from 'gl-matrix'; import {StencilMode} from '../gl/stencil_mode'; @@ -33,11 +32,13 @@ import type {CrossTileID, VariableOffset} from '../symbol/placement'; import type {SymbolBucket, SymbolBuffers} from '../data/bucket/symbol_bucket'; import type {TerrainData} from '../render/terrain'; import type {SymbolLayerSpecification} from '@maplibre/maplibre-gl-style-spec'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {ColorMode} from '../gl/color_mode'; import type {Program} from './program'; import type {TextAnchor} from '../style/style_layer/variable_text_anchor'; -import {createProjection, Projection} from '../geo/projection/projection'; +import {getGlCoordMatrix, getPerspectiveRatio, getPitchedLabelPlaneMatrix, hideGlyphs, projectWithMatrix, projectTileCoordinatesToClipSpace, projectTileCoordinatesToLabelPlane, SymbolProjectionContext, updateLineLabels} from '../symbol/projection'; +import {translatePosition} from '../util/util'; +import type {ProjectionData} from '../geo/projection/projection_data'; type SymbolTileRenderState = { segments: SegmentVector; @@ -47,6 +48,7 @@ type SymbolTileRenderState = { program: Program; buffers: SymbolBuffers; uniformValues: UniformValues; + projectionData: ProjectionData; atlasTexture: Texture; atlasTextureIcon: Texture | null; atlasInterpolation: TextureFilter; @@ -68,8 +70,8 @@ export function drawSymbols(painter: Painter, sourceCache: SourceCache, layer: S const colorMode = painter.colorModeForRenderPass(); const hasVariablePlacement = layer._unevaluatedLayout.hasValue('text-variable-anchor') || layer._unevaluatedLayout.hasValue('text-variable-anchor-offset'); - //Compute variable-offsets before painting since icons and text data positioning - //depend on each other in this case. + // Compute variable-offsets before painting since icons and text data positioning + // depend on each other in this case. if (hasVariablePlacement) { updateVariableAnchors(coords, painter, layer, sourceCache, layer.layout.get('text-rotation-alignment'), @@ -133,7 +135,7 @@ function updateVariableAnchors(coords: Array, translateAnchor: 'map' | 'viewport', variableOffsets: {[_ in CrossTileID]: VariableOffset}) { const transform = painter.transform; - const projection = createProjection(); + const terrain = painter.style.map.terrain; const rotateWithMap = rotationAlignment === 'map'; const pitchWithMap = pitchAlignment === 'map'; @@ -146,20 +148,20 @@ function updateVariableAnchors(coords: Array, const size = evaluateSizeForZoom(sizeData, transform.zoom); const pixelToTileScale = pixelsToTileUnits(tile, 1, painter.transform.zoom); - const labelPlaneMatrix = symbolProjection.getLabelPlaneMatrix(coord.posMatrix, pitchWithMap, rotateWithMap, painter.transform, pixelToTileScale); + const pitchedLabelPlaneMatrix = getPitchedLabelPlaneMatrix(rotateWithMap, painter.transform, pixelToTileScale); const updateTextFitIcon = layer.layout.get('icon-text-fit') !== 'none' && bucket.hasIconData(); if (size) { const tileScale = Math.pow(2, transform.zoom - tile.tileID.overscaledZ); - const getElevation = painter.style.map.terrain ? (x: number, y: number) => painter.style.map.terrain.getElevation(coord, x, y) : null; - const translation = projection.translatePosition(transform, tile, translate, translateAnchor); + const getElevation = terrain ? (x: number, y: number) => terrain.getElevation(coord, x, y) : null; + const translation = translatePosition(transform, tile, translate, translateAnchor); updateVariableAnchorsForBucket(bucket, rotateWithMap, pitchWithMap, variableOffsets, - transform, labelPlaneMatrix, coord.posMatrix, tileScale, size, updateTextFitIcon, projection, translation, coord.toUnwrapped(), getElevation); + transform, pitchedLabelPlaneMatrix, tileScale, size, updateTextFitIcon, translation, coord.toUnwrapped(), getElevation); } } } -function getShiftedAnchor(projectedAnchorPoint: Point, projectionContext: symbolProjection.SymbolProjectionContext, rotateWithMap, shift: Point, transformAngle: number, pitchedTextShiftCorrection: number) { +function getShiftedAnchor(projectedAnchorPoint: Point, projectionContext: SymbolProjectionContext, rotateWithMap, shift: Point, transformAngle: number, pitchedTextShiftCorrection: number) { // Usual case is that we take the projected anchor and add the pixel-based shift // calculated earlier. In the (somewhat weird) case of pitch-aligned text, we add an equivalent // tile-unit based shift to the anchor before projecting to the label plane. @@ -170,12 +172,13 @@ function getShiftedAnchor(projectedAnchorPoint: Point, projectionContext: symbol adjustedShift = adjustedShift.rotate(-transformAngle); } const tileAnchorShifted = translatedAnchor.add(adjustedShift); - return symbolProjection.project(tileAnchorShifted.x, tileAnchorShifted.y, projectionContext.labelPlaneMatrix, projectionContext.getElevation).point; + return projectWithMatrix(tileAnchorShifted.x, tileAnchorShifted.y, projectionContext.pitchedLabelPlaneMatrix, projectionContext.getElevation).point; } else { if (rotateWithMap) { // Compute the angle with which to rotate the anchor, so that it is aligned with // the map's actual east-west axis. Very similar to what is done in the shader. - const projectedAnchorRight = symbolProjection.projectTileCoordinatesToViewport(projectionContext.tileAnchorPoint.x + 1, projectionContext.tileAnchorPoint.y, projectionContext); + // Note that the label plane must be screen pixels here. + const projectedAnchorRight = projectTileCoordinatesToLabelPlane(projectionContext.tileAnchorPoint.x + 1, projectionContext.tileAnchorPoint.y, projectionContext); const east = projectedAnchorRight.point.sub(projectedAnchorPoint); const angle = Math.atan(east.y / east.x) + (east.x < 0 ? Math.PI : 0); return projectedAnchorPoint.add(shift.rotate(angle)); @@ -190,13 +193,11 @@ function updateVariableAnchorsForBucket( rotateWithMap: boolean, pitchWithMap: boolean, variableOffsets: {[_ in CrossTileID]: VariableOffset}, - transform: Transform, - labelPlaneMatrix: mat4, - posMatrix: mat4, + transform: IReadonlyTransform, + pitchedLabelPlaneMatrix: mat4, tileScale: number, size: EvaluatedZoomSize, updateTextFitIcon: boolean, - projection: Projection, translation: [number, number], unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number) { @@ -214,26 +215,26 @@ function updateVariableAnchorsForBucket( if (!variableOffset) { // These symbols are from a justification that is not being used, or a label that wasn't placed // so we don't need to do the extra math to figure out what incremental shift to apply. - symbolProjection.hideGlyphs(symbol.numGlyphs, dynamicTextLayoutVertexArray); + hideGlyphs(symbol.numGlyphs, dynamicTextLayoutVertexArray); } else { const tileAnchor = new Point(symbol.anchorX, symbol.anchorY); - const projectionContext = { + const projectionContext: SymbolProjectionContext = { getElevation, width: transform.width, height: transform.height, - labelPlaneMatrix, + pitchedLabelPlaneMatrix, lineVertexArray: null, pitchWithMap, - projection, + transform, projectionCache: null, tileAnchorPoint: tileAnchor, translation, unwrappedTileID }; const projectedAnchor = pitchWithMap ? - symbolProjection.project(tileAnchor.x, tileAnchor.y, posMatrix, getElevation) : - symbolProjection.projectTileCoordinatesToViewport(tileAnchor.x, tileAnchor.y, projectionContext); - const perspectiveRatio = symbolProjection.getPerspectiveRatio(transform.cameraToCenterDistance, projectedAnchor.signedDistanceFromCamera); + projectTileCoordinatesToClipSpace(tileAnchor.x, tileAnchor.y, projectionContext) : + projectTileCoordinatesToLabelPlane(tileAnchor.x, tileAnchor.y, projectionContext); + const perspectiveRatio = getPerspectiveRatio(transform.cameraToCenterDistance, projectedAnchor.signedDistanceFromCamera); let renderTextSize = evaluateSizeForFeature(bucket.textSizeData, size, symbol) * perspectiveRatio / ONE_EM; if (pitchWithMap) { // Go from size in pixels to equivalent size in tile units @@ -243,7 +244,7 @@ function updateVariableAnchorsForBucket( const {width, height, anchor, textOffset, textBoxScale} = variableOffset; const shift = calculateVariableRenderShift(anchor, width, height, textOffset, textBoxScale, renderTextSize); - const pitchedTextCorrection = projection.getPitchedTextCorrection(transform, tileAnchor.add(new Point(translation[0], translation[1])), unwrappedTileID); + const pitchedTextCorrection = transform.getPitchedTextCorrection(tileAnchor.x + translation[0], tileAnchor.y + translation[1], unwrappedTileID); const shiftedAnchor = getShiftedAnchor(projectedAnchor.point, projectionContext, rotateWithMap, shift, transform.angle, pitchedTextCorrection); const angle = (bucket.allowVerticalPlacement && symbol.placedOrientation === WritingMode.vertical) ? Math.PI / 2 : 0; @@ -263,11 +264,11 @@ function updateVariableAnchorsForBucket( for (let i = 0; i < placedIcons.length; i++) { const placedIcon = placedIcons.get(i); if (placedIcon.hidden) { - symbolProjection.hideGlyphs(placedIcon.numGlyphs, dynamicIconLayoutVertexArray); + hideGlyphs(placedIcon.numGlyphs, dynamicIconLayoutVertexArray); } else { const shift = placedTextShifts[i]; if (!shift) { - symbolProjection.hideGlyphs(placedIcon.numGlyphs, dynamicIconLayoutVertexArray); + hideGlyphs(placedIcon.numGlyphs, dynamicIconLayoutVertexArray); } else { for (let g = 0; g < placedIcon.numGlyphs; g++) { addDynamicAttributes(dynamicIconLayoutVertexArray, shift.shiftedAnchor, shift.angle); @@ -306,19 +307,16 @@ function drawLayerSymbols( const context = painter.context; const gl = context.gl; - const tr = painter.transform; - const projection = createProjection(); + const transform = painter.transform; const rotateWithMap = rotationAlignment === 'map'; const pitchWithMap = pitchAlignment === 'map'; const alongLine = rotationAlignment !== 'viewport' && layer.layout.get('symbol-placement') !== 'point'; // Line label rotation happens in `updateLineLabels` - // Pitched point labels are automatically rotated by the labelPlaneMatrix projection + // Pitched point labels are automatically rotated by the pitchedLabelPlaneMatrix projection // Unpitched point labels need to have their rotation applied after projection const rotateInShader = rotateWithMap && !pitchWithMap && !alongLine; - const isViewportLine = !pitchWithMap && alongLine; - const hasSortKey = !layer.layout.get('symbol-sort-key').isConstant(); let sortFeaturesByKey = false; @@ -328,7 +326,7 @@ function drawLayerSymbols( const tileRenderState: Array = []; - const pitchedTextRescaling = projection.getCircleRadiusCorrection(tr); + const pitchedTextRescaling = transform.getCircleRadiusCorrection(); for (const coord of coords) { const tile = sourceCache.getTile(coord); @@ -342,10 +340,10 @@ function drawLayerSymbols( const isSDF = isText || bucket.sdfIcons; const sizeData = isText ? bucket.textSizeData : bucket.iconSizeData; - const transformed = pitchWithMap || tr.pitch !== 0; + const transformed = pitchWithMap || transform.pitch !== 0; const program = painter.useProgram(getSymbolProgramName(isSDF, isText, bucket), programConfiguration); - const size = evaluateSizeForZoom(sizeData, tr.zoom); + const size = evaluateSizeForZoom(sizeData, transform.zoom); const terrainData = painter.style.map.terrain && painter.style.map.terrain.getTerrainData(coord); let texSize: [number, number]; @@ -373,13 +371,15 @@ function drawLayerSymbols( texSize = tile.imageAtlasTexture.size; } + // See the comment at the beginning of src/symbol/projection.ts for an overview of the symbol projection process const s = pixelsToTileUnits(tile, 1, painter.transform.zoom); - const baseMatrix = isViewportLine ? coord.posMatrix : identityMat4; - const labelPlaneMatrix = symbolProjection.getLabelPlaneMatrix(baseMatrix, pitchWithMap, rotateWithMap, painter.transform, s); - const glCoordMatrixForShader = symbolProjection.getGlCoordMatrix(baseMatrix, pitchWithMap, rotateWithMap, painter.transform, s); - const glCoordMatrixForSymbolPlacement = symbolProjection.getGlCoordMatrix(coord.posMatrix, pitchWithMap, rotateWithMap, painter.transform, s); + const pitchedLabelPlaneMatrix = getPitchedLabelPlaneMatrix(rotateWithMap, painter.transform, s); + const pitchedLabelPlaneMatrixInverse = mat4.create(); + mat4.invert(pitchedLabelPlaneMatrixInverse, pitchedLabelPlaneMatrix); + const glCoordMatrixForShader = getGlCoordMatrix(pitchWithMap, rotateWithMap, painter.transform, s); - const translation = projection.translatePosition(painter.transform, tile, translate, translateAnchor); + const translation = translatePosition(transform, tile, translate, translateAnchor); + const projectionData = transform.getProjectionData(coord); const hasVariableAnchors = hasVariablePlacement && bucket.hasTextData(); const updateTextFitIcon = layer.layout.get('icon-text-fit') !== 'none' && @@ -389,14 +389,16 @@ function drawLayerSymbols( if (alongLine) { const getElevation = painter.style.map.terrain ? (x: number, y: number) => painter.style.map.terrain.getElevation(coord, x, y) : null; const rotateToLine = layer.layout.get('text-rotation-alignment') === 'map'; - symbolProjection.updateLineLabels(bucket, coord.posMatrix, painter, isText, labelPlaneMatrix, glCoordMatrixForSymbolPlacement, pitchWithMap, keepUpright, rotateToLine, projection, coord.toUnwrapped(), tr.width, tr.height, translation, getElevation); + updateLineLabels(bucket, painter, isText, pitchedLabelPlaneMatrix, pitchedLabelPlaneMatrixInverse, pitchWithMap, keepUpright, rotateToLine, coord.toUnwrapped(), transform.width, transform.height, translation, getElevation); } - const matrix = coord.posMatrix; // formerly also incorporated translate and translate-anchor const shaderVariableAnchor = (isText && hasVariablePlacement) || updateTextFitIcon; + + // If the label plane matrix is used, it transforms either map-pitch-aligned pixels, or to screenspace pixels + const combinedLabelPlaneMatrix = pitchWithMap ? pitchedLabelPlaneMatrix : painter.transform.clipSpaceToPixelsMatrix; + // Label plane matrix is unused in the shader if variable anchors are used or the text is placed along a line const noLabelPlane = (alongLine || shaderVariableAnchor); - const uLabelPlaneMatrix = noLabelPlane ? identityMat4 : labelPlaneMatrix; - const uglCoordMatrix = glCoordMatrixForShader; // formerly also incorporated translate and translate-anchor + const uLabelPlaneMatrix = noLabelPlane ? identityMat4 : combinedLabelPlaneMatrix; const hasHalo = isSDF && layer.paint.get(isText ? 'text-halo-width' : 'icon-halo-width').constantOr(1) !== 0; @@ -404,23 +406,24 @@ function drawLayerSymbols( if (isSDF) { if (!bucket.iconsInText) { uniformValues = symbolSDFUniformValues(sizeData.kind, - size, rotateInShader, pitchWithMap, alongLine, shaderVariableAnchor, painter, matrix, - uLabelPlaneMatrix, uglCoordMatrix, translation, isText, texSize, true, pitchedTextRescaling); + size, rotateInShader, pitchWithMap, alongLine, shaderVariableAnchor, painter, + uLabelPlaneMatrix, glCoordMatrixForShader, translation, isText, texSize, true, pitchedTextRescaling); } else { uniformValues = symbolTextAndIconUniformValues(sizeData.kind, - size, rotateInShader, pitchWithMap, alongLine, shaderVariableAnchor, painter, matrix, - uLabelPlaneMatrix, uglCoordMatrix, translation, texSize, texSizeIcon, pitchedTextRescaling); + size, rotateInShader, pitchWithMap, alongLine, shaderVariableAnchor, painter, + uLabelPlaneMatrix, glCoordMatrixForShader, translation, texSize, texSizeIcon, pitchedTextRescaling); } } else { uniformValues = symbolIconUniformValues(sizeData.kind, - size, rotateInShader, pitchWithMap, alongLine, shaderVariableAnchor, painter, matrix, - uLabelPlaneMatrix, uglCoordMatrix, translation, isText, texSize, pitchedTextRescaling); + size, rotateInShader, pitchWithMap, alongLine, shaderVariableAnchor, painter, + uLabelPlaneMatrix, glCoordMatrixForShader, translation, isText, texSize, pitchedTextRescaling); } const state = { program, buffers, uniformValues, + projectionData, atlasTexture, atlasTextureIcon, atlasInterpolation, @@ -470,11 +473,11 @@ function drawLayerSymbols( const uniformValues = state.uniformValues; if (state.hasHalo) { uniformValues['u_is_halo'] = 1; - drawSymbolElements(state.buffers, segmentState.segments, layer, painter, state.program, depthMode, stencilMode, colorMode, uniformValues, segmentState.terrainData); + drawSymbolElements(state.buffers, segmentState.segments, layer, painter, state.program, depthMode, stencilMode, colorMode, uniformValues, state.projectionData, segmentState.terrainData); } uniformValues['u_is_halo'] = 0; } - drawSymbolElements(state.buffers, segmentState.segments, layer, painter, state.program, depthMode, stencilMode, colorMode, state.uniformValues, segmentState.terrainData); + drawSymbolElements(state.buffers, segmentState.segments, layer, painter, state.program, depthMode, stencilMode, colorMode, state.uniformValues, state.projectionData, segmentState.terrainData); } } @@ -488,11 +491,12 @@ function drawSymbolElements( stencilMode: StencilMode, colorMode: Readonly, uniformValues: UniformValues, + projectionData: ProjectionData, terrainData: TerrainData) { const context = painter.context; const gl = context.gl; - program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, terrainData, layer.id, buffers.layoutVertexBuffer, + program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.backCCW, + uniformValues, terrainData, projectionData, 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 aa3cfc2221..90badfaec8 100644 --- a/src/render/draw_terrain.ts +++ b/src/render/draw_terrain.ts @@ -16,6 +16,7 @@ import {Terrain} from './terrain'; function drawDepth(painter: Painter, terrain: Terrain) { const context = painter.context; const gl = context.gl; + const tr = painter.transform; const colorMode = ColorMode.unblended; const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, [0, 1]); const mesh = terrain.getTerrainMesh(); @@ -26,9 +27,9 @@ function drawDepth(painter: Painter, terrain: Terrain) { context.clear({color: Color.transparent, depth: 1}); for (const tile of tiles) { 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); + const projectionData = tr.getProjectionData(tile.tileID, false, true); + const uniformValues = terrainDepthUniformValues(terrain.getMeshFrameDelta(tr.zoom)); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, projectionData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); } context.bindFramebuffer.set(null); context.viewport.set([0, 0, painter.width, painter.height]); @@ -42,6 +43,7 @@ function drawDepth(painter: Painter, terrain: Terrain) { function drawCoords(painter: Painter, terrain: Terrain) { const context = painter.context; const gl = context.gl; + const tr = painter.transform; const colorMode = ColorMode.unblended; const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, [0, 1]); const mesh = terrain.getTerrainMesh(); @@ -58,9 +60,9 @@ function drawCoords(painter: Painter, terrain: Terrain) { const terrainData = terrain.getTerrainData(tile.tileID); context.activeTexture.set(gl.TEXTURE0); 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); + const uniformValues = terrainCoordsUniformValues(255 - terrain.coordsIndex.length, terrain.getMeshFrameDelta(tr.zoom)); + const projectionData = tr.getProjectionData(tile.tileID, false, true); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, projectionData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); terrain.coordsIndex.push(tile.tileID.key); } context.bindFramebuffer.set(null); @@ -70,6 +72,7 @@ function drawCoords(painter: Painter, terrain: Terrain) { function drawTerrain(painter: Painter, terrain: Terrain, tiles: Array) { const context = painter.context; const gl = context.gl; + const tr = painter.transform; const colorMode = painter.colorModeForRenderPass(); const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, painter.depthRangeFor3D); const program = painter.useProgram('terrain'); @@ -83,13 +86,12 @@ function drawTerrain(painter: Painter, terrain: Terrain, tiles: Array) { const terrainData = terrain.getTerrainData(tile.tileID); context.activeTexture.set(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture.texture); - const posMatrix = painter.transform.calculatePosMatrix(tile.tileID.toUnwrapped()); - const eleDelta = terrain.getMeshFrameDelta(painter.transform.zoom); - const fogMatrix = painter.transform.calculateFogMatrix(tile.tileID.toUnwrapped()); - const uniformValues = terrainUniformValues(posMatrix, eleDelta, fogMatrix, painter.style.sky, painter.transform.pitch); - program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + const eleDelta = terrain.getMeshFrameDelta(tr.zoom); + const fogMatrix = tr.calculateFogMatrix(tile.tileID.toUnwrapped()); + const uniformValues = terrainUniformValues(eleDelta, fogMatrix, painter.style.sky, tr.pitch); + const projectionData = tr.getProjectionData(tile.tileID, false, true); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, projectionData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); } - } export { diff --git a/src/render/fill_large_mesh_arrays.test.ts b/src/render/fill_large_mesh_arrays.test.ts new file mode 100644 index 0000000000..3a647df956 --- /dev/null +++ b/src/render/fill_large_mesh_arrays.test.ts @@ -0,0 +1,437 @@ +import {FillLayoutArray, LineIndexArray, TriangleIndexArray} from '../data/array_types.g'; +import {SegmentVector} from '../data/segment'; +import {fillLargeMeshArrays} from './fill_large_mesh_arrays'; +import {SimpleMesh, getGridMesh, getGridMeshRandom} from '../../test/unit/lib/mesh_utils'; + +describe('fillArrays', () => { + test('Mesh comparison works', () => { + const meshA: SimpleMesh = { + vertices: [ + 0, 0, // 0 0 ---- 1 + 1, 0, // 1 | | + 1, 1, // 2 | | + 0, 1 // 3 3 ---- 2 + ], + indicesTriangles: [ + 0, 3, 1, + 3, 2, 1 + ], + indicesLines: [ + 0, 1, + 1, 2, + 2, 3, + 3, 0 + ], + segmentsTriangles: [ + { + vertexOffset: 0, + primitiveLength: 2, + primitiveOffset: 0, + } + ], + segmentsLines: [ + { + vertexOffset: 0, + primitiveLength: 4, + primitiveOffset: 0, + } + ] + }; + + // Check string representation + const stringsA = getRenderedGeometryRepresentation(meshA); + expect(stringsA.stringsTriangles).toEqual(['(0 0) (0 1) (1 0)', '(0 1) (1 1) (1 0)']); + expect(stringsA.stringsLines).toEqual(['(0 0) (1 0)', '(1 0) (1 1)', '(1 1) (0 1)', '(0 1) (0 0)']); + + const meshB: SimpleMesh = { + vertices: [ + 0, 0, // 0 + 0, 1, // 1 + 1, 0, // 2 + 0, 1, // 3 + 1, 1, // 4 + 1, 0, // 5 + 0, 0, + 1, 0, + 1, 1, + 0, 1, + ], + indicesTriangles: [ + 0, 1, 2, + 0, 1, 2 + ], + indicesLines: [ + 0, 1, + 1, 2, + 2, 3, + 3, 0 + ], + segmentsTriangles: [ + { + vertexOffset: 0, + primitiveLength: 1, + primitiveOffset: 0, + }, + { + vertexOffset: 3, + primitiveLength: 1, + primitiveOffset: 1, + } + ], + segmentsLines: [ + { + vertexOffset: 6, + primitiveLength: 4, + primitiveOffset: 0, + } + ] + }; + + testMeshesEqual(meshA, meshB); + + // same as mesh A, but contains one error + const meshC: SimpleMesh = { + vertices: [ + 0, 0, // 0 0 ---- 1 + 1, 0, // 1 | | + 1, 1, // 2 | | + 0, 1 // 3 3 ---- 2 + ], + indicesTriangles: [ + 0, 3, 1, + 1, 2, 3 // flip vertex order + ], + indicesLines: [ + 0, 1, + 1, 2, + 2, 3, + 3, 0 + ], + segmentsTriangles: [ + { + vertexOffset: 0, + primitiveLength: 2, + primitiveOffset: 0, + } + ], + segmentsLines: [ + { + vertexOffset: 0, + primitiveLength: 4, + primitiveOffset: 0, + } + ] + }; + const stringsC = getRenderedGeometryRepresentation(meshC); + // String representations should be different + expect(stringsC.stringsTriangles).not.toEqual(stringsA.stringsTriangles); + }); + + test('Mesh grid generation', () => { + const mesh = getGridMesh(2); + const strings = getRenderedGeometryRepresentation(mesh); + // Note that this forms a correct 2x2 quad mesh. + expect(strings.stringsTriangles).toEqual([ + '(0 0) (1 1) (1 0)', + '(0 0) (0 1) (1 1)', + '(1 0) (2 1) (2 0)', + '(1 0) (1 1) (2 1)', + '(0 1) (1 2) (1 1)', + '(0 1) (0 2) (1 2)', + '(1 1) (2 2) (2 1)', + '(1 1) (1 2) (2 2)' + ]); + expect(strings.stringsLines).toEqual([ + '(0 0) (1 0)', + '(1 0) (2 0)', + '(0 2) (1 2)', + '(1 2) (2 2)', + '(0 0) (0 1)', + '(0 1) (0 2)', + '(2 0) (2 1)', + '(2 1) (2 2)' + ]); + }); + + test('Tiny mesh is unchanged.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const mesh = getGridMesh(1); + const split = createSegmentsAndSplitMesh(mesh); + expect(split.segmentsTriangles).toHaveLength(1); + testMeshesEqual(mesh, split); + }); + + test('Small mesh is unchanged.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const mesh = getGridMesh(2); + const split = createSegmentsAndSplitMesh(mesh); + expect(split.segmentsTriangles).toHaveLength(1); + testMeshesEqual(mesh, split); + }); + + test('Large mesh is correctly split into multiple segments.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + const mesh = getGridMesh(4); + const split = createSegmentsAndSplitMesh(mesh); + expect(split.segmentsTriangles.length).toBeGreaterThan(1); + testMeshesEqual(mesh, split); + }); + + test('Very large mesh is correctly split into multiple segments.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 1024; + const mesh = getGridMesh(64); + const split = createSegmentsAndSplitMesh(mesh); + expect(split.segmentsTriangles.length).toBeGreaterThan(1); + testMeshesEqual(mesh, split); + }); + + test('Very large random mesh is correctly split into multiple segments.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 1024; + const mesh = getGridMeshRandom(64, 8192, 1024); + const split = createSegmentsAndSplitMesh(mesh); + expect(split.segmentsTriangles.length).toBeGreaterThan(1); + testMeshesEqual(mesh, split); + }); + + test('Several small meshes are correctly placed into a single segment.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + + const buffers = createMeshBuffers(); + + const smallMesh = getGridMesh(1); // 4 vertices + + fillMesh(buffers, smallMesh); + fillMesh(buffers, smallMesh); + const result = convertBuffersToMesh(buffers); + expect(result.vertices).toEqual([ + 0, 0, // 0 + 1, 0, // 1 + 0, 1, // 2 + 1, 1, // 3 + 0, 0, + 1, 0, + 0, 1, + 1, 1 + ]); + expect(result.indicesTriangles).toEqual([ + 0, 3, 1, + 0, 2, 3, + 4, 7, 5, + 4, 6, 7 + ]); + expect(result.indicesLines).toEqual([ + 0, 1, 2, 3, 0, 2, 1, 3, + 4, 5, 6, 7, 4, 6, 5, 7 + ]); + expect(result.segmentsTriangles).toHaveLength(1); + expect(result.segmentsLines).toHaveLength(1); + }); + + test('Several small and large meshes are correctly split into multiple segments.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + + const buffers = createMeshBuffers(); + + const smallMesh = getGridMesh(1); // 4 vertices + const largeMesh = getGridMesh(2); // 9 vertices + + const meshList = [ + smallMesh, + largeMesh, + // Previous mesh still fits into first segment: 9+4 = 13 + largeMesh, + // Only the first triangle fits, usage is second segment is 8 vertices + smallMesh, + // This last one brings up second segment usage to 12 vertices + ]; + + for (const mesh of meshList) { + fillMesh(buffers, mesh); + } + + const result = convertBuffersToMesh(buffers); + const merge = mergeMeshes(meshList); + + expect(result.segmentsTriangles).toHaveLength(2); + expect(result.segmentsTriangles[0].primitiveLength).toBe(10); // 2 + 8 triangles + expect(result.segmentsTriangles[1].primitiveLength).toBe(10); // 8 + 2 triangles + testMeshesEqual(merge, result); + }); + + test('Many small and large meshes are correctly split into multiple segments.', () => { + SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16; + + const buffers = createMeshBuffers(); + + const smallMesh = getGridMesh(1); // 4 vertices + const largeMesh = getGridMesh(2); // 9 vertices + + const meshList = [ + smallMesh, + largeMesh, + largeMesh, + smallMesh, + smallMesh, + smallMesh, + largeMesh, + largeMesh, + largeMesh, + largeMesh, + largeMesh, + ]; + + for (const mesh of meshList) { + fillMesh(buffers, mesh); + } + + const result = convertBuffersToMesh(buffers); + const merge = mergeMeshes(meshList); + testMeshesEqual(merge, result); + expect(result.segmentsTriangles.length).toBeGreaterThan(merge.vertices.length / 2 / SegmentVector.MAX_VERTEX_ARRAY_LENGTH); + expect(result.segmentsTriangles.length).toBeLessThan(meshList.length); + }); +}); + +type MeshBuffers = { + segmentsTriangles: SegmentVector; + segmentsLines: SegmentVector; + vertices: FillLayoutArray; + indicesTriangles: TriangleIndexArray; + indicesLines: LineIndexArray; +}; + +function createMeshBuffers(): MeshBuffers { + return { + segmentsTriangles: new SegmentVector(), + segmentsLines: new SegmentVector(), + vertices: new FillLayoutArray(), + indicesTriangles: new TriangleIndexArray(), + indicesLines: new LineIndexArray(), + }; +} + +/** + * Creates a mesh the geometry of which is a merge of the specified input meshes, + * useful for comparing the result of using {@link fillLargeMeshArrays} on several meshes. + */ +function mergeMeshes(meshes: Array): SimpleMesh { + const result: SimpleMesh = { + vertices: [], + indicesTriangles: [], + indicesLines: [], + segmentsTriangles: [], + segmentsLines: [], + }; + + for (const mesh of meshes) { + const baseVertex = result.vertices.length / 2; + result.vertices.push(...mesh.vertices); + result.indicesTriangles.push(...(mesh.indicesTriangles.map(x => x + baseVertex))); + result.indicesLines.push(...(mesh.indicesLines.map(x => x + baseVertex))); + } + + result.segmentsTriangles.push({ + vertexOffset: 0, + primitiveOffset: 0, + primitiveLength: result.indicesTriangles.length / 3, + }); + result.segmentsLines.push({ + vertexOffset: 0, + primitiveOffset: 0, + primitiveLength: result.indicesLines.length / 2, + }); + + return result; +} + +/** + * Creates a mesh that is equal to the actual rendered output of a single + * {@link fillLargeMeshArrays} call that is run in isolation. + */ +function createSegmentsAndSplitMesh(mesh: SimpleMesh): SimpleMesh { + const buffers = createMeshBuffers(); + fillMesh(buffers, mesh); + return convertBuffersToMesh(buffers); +} + +function fillMesh(buffers: MeshBuffers, mesh: SimpleMesh): void { + fillLargeMeshArrays( + (x, y) => { + buffers.vertices.emplaceBack(x, y); + }, + buffers.segmentsTriangles, + buffers.vertices, + buffers.indicesTriangles, + mesh.vertices, + mesh.indicesTriangles, + buffers.segmentsLines, + buffers.indicesLines, + [mesh.indicesLines]); +} + +function convertBuffersToMesh(buffers: MeshBuffers): SimpleMesh { + return { + segmentsTriangles: buffers.segmentsTriangles.segments, + segmentsLines: buffers.segmentsLines.segments, + vertices: Array.from(buffers.vertices.int16).slice(0, buffers.vertices.length * 2), + indicesTriangles: Array.from(buffers.indicesTriangles.uint16).slice(0, buffers.indicesTriangles.length * 3), + indicesLines: Array.from(buffers.indicesLines.uint16).slice(0, buffers.indicesLines.length * 2) + }; +} + +/** + * Our goal is to check that a mesh (in this context, a mesh is a vertex buffer, index buffer and segment vector) + * with potentially more than `SegmentVector.MAX_VERTEX_ARRAY_LENGTH` vertices results in the same rendered geometry + * as the result of passing that mesh through `fillLargeMeshArrays`, which creates a mesh that respects the vertex count limit. + * @param expected - The original mesh that might overflow the vertex count limit. + * @param actual - The result of passing the original mesh through `fillLargeMeshArrays`. + */ +function testMeshesEqual(expected: SimpleMesh, actual: SimpleMesh) { + const stringsExpected = getRenderedGeometryRepresentation(expected); + const stringsActual = getRenderedGeometryRepresentation(actual); + expect(stringsActual.stringsTriangles).toEqual(stringsExpected.stringsTriangles); + expect(stringsActual.stringsLines).toEqual(stringsExpected.stringsLines); +} + +/** + * Returns an ordered string representation of the geometry that would be fetched by the GPU's vertex fetch + * if it were to draw the specified mesh segments, respecting `vertexOffset` and `primitiveOffset`. + */ +function getRenderedGeometryRepresentation(mesh: SimpleMesh) { + const stringsTriangles = []; + const stringsLines = []; + + for (const s of mesh.segmentsTriangles) { + for (let i = 0; i < s.primitiveLength; i++) { + const i0 = s.vertexOffset + mesh.indicesTriangles[(s.primitiveOffset + i) * 3]; + const i1 = s.vertexOffset + mesh.indicesTriangles[(s.primitiveOffset + i) * 3 + 1]; + const i2 = s.vertexOffset + mesh.indicesTriangles[(s.primitiveOffset + i) * 3 + 2]; + const v0x = mesh.vertices[i0 * 2]; + const v0y = mesh.vertices[i0 * 2 + 1]; + const v1x = mesh.vertices[i1 * 2]; + const v1y = mesh.vertices[i1 * 2 + 1]; + const v2x = mesh.vertices[i2 * 2]; + const v2y = mesh.vertices[i2 * 2 + 1]; + const str = `(${v0x} ${v0y}) (${v1x} ${v1y}) (${v2x} ${v2y})`; + stringsTriangles.push(str); + } + } + + for (const s of mesh.segmentsLines) { + for (let i = 0; i < s.primitiveLength; i++) { + const i0 = s.vertexOffset + mesh.indicesLines[(s.primitiveOffset + i) * 2]; + const i1 = s.vertexOffset + mesh.indicesLines[(s.primitiveOffset + i) * 2 + 1]; + const v0x = mesh.vertices[i0 * 2]; + const v0y = mesh.vertices[i0 * 2 + 1]; + const v1x = mesh.vertices[i1 * 2]; + const v1y = mesh.vertices[i1 * 2 + 1]; + const str = `(${v0x} ${v0y}) (${v1x} ${v1y})`; + stringsLines.push(str); + } + } + + return { + stringsTriangles, + stringsLines + }; +} diff --git a/src/render/fill_large_mesh_arrays.ts b/src/render/fill_large_mesh_arrays.ts new file mode 100644 index 0000000000..6a54b68d7e --- /dev/null +++ b/src/render/fill_large_mesh_arrays.ts @@ -0,0 +1,261 @@ +import {LineIndexArray, TriangleIndexArray} from '../data/array_types.g'; +import {Segment, SegmentVector} from '../data/segment'; +import {StructArray} from '../util/struct_array'; + +/** + * This function will take any "mesh" and fill in into vertex buffers, breaking it up into multiple drawcalls as needed + * if too many (\>65535) vertices are used. + * This function is mainly intended for use with subdivided geometry, since sometimes subdivision might generate + * more vertices than what fits into 16 bit indices. + * + * Accepts a triangle mesh, optionally with a line list (for fill outlines) as well. The triangle and line segments are expected to share a single vertex buffer. + * + * Mutates the provided `segmentsTriangles` and `segmentsLines` SegmentVectors, + * `vertexArray`, `triangleIndexArray` and optionally `lineIndexArray`. + * Does not mutate the input `flattened` vertices, `triangleIndices` and `lineList`. + * @param addVertex - A function for adding a new vertex into `vertexArray`. We might sometimes want to add more values per vertex than just X and Y coordinates, which can be handled in this function. + * @param segmentsTriangles - The segment array for triangle draw calls. New segments will be placed here. + * @param vertexArray - The vertex array into which new vertices are placed by the provided `addVertex` function. + * @param triangleIndexArray - Index array for drawing triangles. New triangle indices are placed here. + * @param flattened - The input flattened array or vertex coordinates. + * @param triangleIndices - Triangle indices into `flattened`. + * @param segmentsLines - Segment array for line draw calls. New segments will be placed here. Only needed if the mesh also contains lines. + * @param lineIndexArray - Index array for drawing lines. New triangle indices are placed here. Only needed if the mesh also contains lines. + * @param lineList - Line indices into `flattened`. Only needed if the mesh also contains lines. + */ +export function fillLargeMeshArrays( + addVertex: (x: number, y: number) => void, + segmentsTriangles: SegmentVector, + vertexArray: StructArray, + triangleIndexArray: TriangleIndexArray, + flattened: Array, + triangleIndices: Array, + segmentsLines?: SegmentVector, + lineIndexArray?: LineIndexArray, + lineList?: Array>) { + + const numVertices = flattened.length / 2; + const hasLines = segmentsLines && lineIndexArray && lineList; + + if (numVertices < SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { + // The fast path - no segmentation needed + const triangleSegment = segmentsTriangles.prepareSegment(numVertices, vertexArray, triangleIndexArray); + const triangleIndex = triangleSegment.vertexLength; + + for (let i = 0; i < triangleIndices.length; i += 3) { + triangleIndexArray.emplaceBack( + triangleIndex + triangleIndices[i], + triangleIndex + triangleIndices[i + 1], + triangleIndex + triangleIndices[i + 2]); + } + + triangleSegment.vertexLength += numVertices; + triangleSegment.primitiveLength += triangleIndices.length / 3; + + let lineIndicesStart: number; + let lineSegment: Segment; + + if (hasLines) { + // Note that segment creation must happen *before* we add vertices into the vertex buffer + lineSegment = segmentsLines.prepareSegment(numVertices, vertexArray, lineIndexArray); + lineIndicesStart = lineSegment.vertexLength; + lineSegment.vertexLength += numVertices; + } + + // Add vertices into vertex buffer + for (let i = 0; i < flattened.length; i += 2) { + addVertex(flattened[i], flattened[i + 1]); + } + + if (hasLines) { + for (let listIndex = 0; listIndex < lineList.length; listIndex++) { + const lineIndices = lineList[listIndex]; + + for (let i = 1; i < lineIndices.length; i += 2) { + lineIndexArray.emplaceBack( + lineIndicesStart + lineIndices[i - 1], + lineIndicesStart + lineIndices[i]); + } + + lineSegment.primitiveLength += lineIndices.length / 2; + } + } + } else { + // Assumption: the incoming triangle indices use vertices in roughly linear order, + // for example a grid of quads where both vertices and quads are created row by row would satisfy this. + // Some completely random arbitrary vertex/triangle order would not. + // Thus, if we encounter a vertex that doesn't fit into MAX_VERTEX_ARRAY_LENGTH, + // we can just stop appending into the old segment and start a new segment and only append to the new segment, + // copying vertices that are already present in the old segment into the new segment if needed, + // because there will not be too many of such vertices. + + // Normally, (out)lines share the same vertex buffer as triangles, but since we need to somehow split it into several drawcalls, + // it is easier to just consider (out)lines separately and duplicate their vertices. + + fillSegmentsTriangles(segmentsTriangles, vertexArray, triangleIndexArray, flattened, triangleIndices, addVertex); + if (hasLines) { + fillSegmentsLines(segmentsLines, vertexArray, lineIndexArray, flattened, lineList, addVertex); + } + + // Triangles and lines share the same vertex buffer, and they usually also share the same vertices. + // But this method might create the vertices for triangles and for lines separately, and thus increasing the vertex count + // of the triangle and line segments by different amounts. + + // The non-splitting fillLargeMeshArrays logic (and old fill-bucket logic) assumes the vertex counts to be the same, + // and forcing both SegmentVectors to return a new segment upon next prepare call satisfies this. + segmentsTriangles.forceNewSegmentOnNextPrepare(); + segmentsLines?.forceNewSegmentOnNextPrepare(); + } +} + +/** + * Determines the new index of a vertex given by its old index. + * @param actualVertexIndices - Array that maps the old index of a given vertex to a new index in the final vertex buffer. + * @param flattened - Old vertex buffer. + * @param addVertex - Function for creating a new vertex in the final vertex buffer. + * @param totalVerticesCreated - Reference to an int holding how many vertices were added to the final vertex buffer. + * @param oldIndex - The old index of the desired vertex. + * @param needsCopy - Whether to duplicate the desired vertex in the final vertex buffer. + * @param segment - The current segment. + * @returns Index of the vertex in the final vertex array. + */ +function copyOrReuseVertex( + actualVertexIndices: Array, + flattened: Array, + addVertex: (x: number, y: number) => void, + totalVerticesCreated: {count: number}, + oldIndex: number, + needsCopy: boolean, + segment: Segment +): number { + if (needsCopy) { + const newIndex = totalVerticesCreated.count; + addVertex(flattened[oldIndex * 2], flattened[oldIndex * 2 + 1]); + actualVertexIndices[oldIndex] = totalVerticesCreated.count; + totalVerticesCreated.count++; + segment.vertexLength++; + return newIndex; + } else { + return actualVertexIndices[oldIndex]; + } +} + +function fillSegmentsTriangles( + segmentsTriangles: SegmentVector, + vertexArray: StructArray, + triangleIndexArray: TriangleIndexArray, + flattened: Array, + triangleIndices: Array, + addVertex: (x: number, y: number) => void +) { + // Array, or rather a map of [vertex index in the original data] -> index of the latest copy of this vertex in the final vertex buffer. + const actualVertexIndices: Array = []; + for (let i = 0; i < flattened.length / 2; i++) { + actualVertexIndices.push(-1); + } + + const totalVerticesCreated = {count: 0}; + + let currentSegmentCutoff = 0; + let segment = segmentsTriangles.getOrCreateLatestSegment(vertexArray, triangleIndexArray); + let baseVertex = segment.vertexLength; + + for (let primitiveEndIndex = 2; primitiveEndIndex < triangleIndices.length; primitiveEndIndex += 3) { + const i0 = triangleIndices[primitiveEndIndex - 2]; + const i1 = triangleIndices[primitiveEndIndex - 1]; + const i2 = triangleIndices[primitiveEndIndex]; + + let i0needsVertexCopy = actualVertexIndices[i0] < currentSegmentCutoff; + let i1needsVertexCopy = actualVertexIndices[i1] < currentSegmentCutoff; + let i2needsVertexCopy = actualVertexIndices[i2] < currentSegmentCutoff; + + const vertexCopyCount = (i0needsVertexCopy ? 1 : 0) + (i1needsVertexCopy ? 1 : 0) + (i2needsVertexCopy ? 1 : 0); + + // Will needed vertex copies fit into this segment? + if (segment.vertexLength + vertexCopyCount > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { + // Break up into a new segment if not. + segment = segmentsTriangles.createNewSegment(vertexArray, triangleIndexArray); + currentSegmentCutoff = totalVerticesCreated.count; + i0needsVertexCopy = true; + i1needsVertexCopy = true; + i2needsVertexCopy = true; + baseVertex = 0; + } + + const actualIndex0 = copyOrReuseVertex( + actualVertexIndices, flattened, addVertex, totalVerticesCreated, + i0, i0needsVertexCopy, segment); + const actualIndex1 = copyOrReuseVertex( + actualVertexIndices, flattened, addVertex, totalVerticesCreated, + i1, i1needsVertexCopy, segment); + const actualIndex2 = copyOrReuseVertex( + actualVertexIndices, flattened, addVertex, totalVerticesCreated, + i2, i2needsVertexCopy, segment); + + triangleIndexArray.emplaceBack( + baseVertex + actualIndex0 - currentSegmentCutoff, + baseVertex + actualIndex1 - currentSegmentCutoff, + baseVertex + actualIndex2 - currentSegmentCutoff + ); + + segment.primitiveLength++; + } +} + +function fillSegmentsLines( + segmentsLines: SegmentVector, + vertexArray: StructArray, + lineIndexArray: LineIndexArray, + flattened: Array, + lineList: Array>, + addVertex: (x: number, y: number) => void +) { + // Array, or rather a map of [vertex index in the original data] -> index of the latest copy of this vertex in the final vertex buffer. + const actualVertexIndices: Array = []; + for (let i = 0; i < flattened.length / 2; i++) { + actualVertexIndices.push(-1); + } + + const totalVerticesCreated = {count: 0}; + + let currentSegmentCutoff = 0; + let segment = segmentsLines.getOrCreateLatestSegment(vertexArray, lineIndexArray); + let baseVertex = segment.vertexLength; + + for (let lineListIndex = 0; lineListIndex < lineList.length; lineListIndex++) { + const currentLine = lineList[lineListIndex]; + for (let lineVertex = 1; lineVertex < lineList[lineListIndex].length; lineVertex += 2) { + const i0 = currentLine[lineVertex - 1]; + const i1 = currentLine[lineVertex]; + + let i0needsVertexCopy = actualVertexIndices[i0] < currentSegmentCutoff; + let i1needsVertexCopy = actualVertexIndices[i1] < currentSegmentCutoff; + + const vertexCopyCount = (i0needsVertexCopy ? 1 : 0) + (i1needsVertexCopy ? 1 : 0); + + // Will needed vertex copies fit into this segment? + if (segment.vertexLength + vertexCopyCount > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { + // Break up into a new segment if not. + segment = segmentsLines.createNewSegment(vertexArray, lineIndexArray); + currentSegmentCutoff = totalVerticesCreated.count; + i0needsVertexCopy = true; + i1needsVertexCopy = true; + baseVertex = 0; + } + + const actualIndex0 = copyOrReuseVertex( + actualVertexIndices, flattened, addVertex, totalVerticesCreated, + i0, i0needsVertexCopy, segment); + const actualIndex1 = copyOrReuseVertex( + actualVertexIndices, flattened, addVertex, totalVerticesCreated, + i1, i1needsVertexCopy, segment); + + lineIndexArray.emplaceBack( + baseVertex + actualIndex0 - currentSegmentCutoff, + baseVertex + actualIndex1 - currentSegmentCutoff + ); + + segment.primitiveLength++; + } + } +} diff --git a/src/render/painter.test.ts b/src/render/painter.test.ts index d5992a2fc6..afb2972f5b 100644 --- a/src/render/painter.test.ts +++ b/src/render/painter.test.ts @@ -1,43 +1,17 @@ -import {TerrainSpecification} from '@maplibre/maplibre-gl-style-spec'; - import {Painter} from './painter'; -import {Transform} from '../geo/transform'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; import {Style} from '../style/style'; -import {Evented} from '../util/evented'; -import {RequestManager} from '../util/request_manager'; - -class StubMap extends Evented { - style: Style; - transform: Transform; - private _requestManager: RequestManager; - _terrain: TerrainSpecification; - - constructor() { - super(); - this.transform = new Transform(); - this._requestManager = new RequestManager(); - } - - _getMapId() { - return 1; - } - - getPixelRatio() { - return 1; - } - - setTerrain(terrain) { this._terrain = terrain; } - getTerrain() { return this._terrain; } -} +import {StubMap} from '../util/test/util'; const getStubMap = () => new StubMap() as any; test('Render must not fail with incompletely loaded style', () => { const gl = document.createElement('canvas').getContext('webgl'); - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); const painter = new Painter(gl, transform); const map = getStubMap(); const style = new Style(map); + style._setProjectionInternal('mercator'); style._updatePlacement(transform, false, 0, false); painter.render(style, { fadeDuration: 0, diff --git a/src/render/painter.ts b/src/render/painter.ts index 59f9896ac6..96bad92874 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'; @@ -33,11 +31,11 @@ import {drawDebug, drawDebugPadding, selectDebugSource} from './draw_debug'; import {drawCustom} from './draw_custom'; import {drawDepth, drawCoords} from './draw_terrain'; import {OverscaledTileID} from '../source/tile_id'; -import {RenderToTexture} from './render_to_texture'; -import {drawSky} from './draw_sky'; +import {drawSky, drawAtmosphere} from './draw_sky'; +import {Mesh} from './mesh'; +import {MercatorShaderDefine, MercatorShaderVariantKey} from '../geo/projection/mercator'; -import type {Transform} from '../geo/transform'; -import type {Tile} from '../source/tile'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {Style} from '../style/style'; import type {StyleLayer} from '../style/style_layer'; import type {CrossFaded} from '../style/properties'; @@ -48,6 +46,8 @@ import type {VertexBuffer} from '../gl/vertex_buffer'; 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 type {RenderToTexture} from './render_to_texture'; +import type {ProjectionData} from '../geo/projection/projection_data'; export type RenderPass = 'offscreen' | 'opaque' | 'translucent'; @@ -67,7 +67,7 @@ type PainterOptions = { */ export class Painter { context: Context; - transform: Transform; + transform: IReadonlyTransform; renderToTexture: RenderToTexture; _tileTextures: { [_: number]: Array; @@ -80,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; @@ -113,7 +117,7 @@ export class Painter { // every time the camera-matrix changes the terrain-facilitators will be redrawn. terrainFacilitator: {dirty: boolean; matrix: mat4; renderTime: number}; - constructor(gl: WebGLRenderingContext | WebGL2RenderingContext, transform: Transform) { + constructor(gl: WebGLRenderingContext | WebGL2RenderingContext, transform: IReadonlyTransform) { this.context = new Context(gl); this.transform = transform; this._tileTextures = {}; @@ -173,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); @@ -190,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); } /* @@ -218,43 +232,79 @@ 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 = { + mainMatrix: matrix, + tileMercatorCoords: [0, 0, 1, 1], + clippingPlane: [0, 0, 0, 0], + projectionTransition: 0.0, + fallbackMatrix: 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); } - _renderTileClippingMasks(layer: StyleLayer, tileIDs: Array) { - if (this.currentStencilSource === layer.source || !layer.isTileClipped() || !tileIDs || !tileIDs.length) return; + _renderTileClippingMasks(layer: StyleLayer, tileIDs: Array, renderToTexture: boolean) { + if (this.currentStencilSource === layer.source || !layer.isTileClipped() || !tileIDs || !tileIDs.length) { + return; + } this.currentStencilSource = layer.source; - const context = this.context; - const gl = context.gl; - if (this.nextStencilID + tileIDs.length > 256) { // we'll run out of fresh IDs so we need to clear and start from scratch this.clearStencil(); } + const context = this.context; context.setColorMode(ColorMode.disabled); context.setDepthMode(DepthMode.disabled); - const program = this.useProgram('clippingMask'); + const stencilRefs = {}; + + // Set stencil ref values for all tiles + for (const tileID of tileIDs) { + stencilRefs[tileID.key] = this.nextStencilID++; + } + + // A two-pass approach is needed. See comment in draw_raster.ts for more details. + // However, we use a simpler approach because we don't care about overdraw here. - this._tileClippingMaskIDs = {}; + // First pass - draw tiles with borders and with GL_ALWAYS + this._renderTileMasks(stencilRefs, tileIDs, renderToTexture, true); + // Second pass - draw borderless tiles with GL_ALWAYS + this._renderTileMasks(stencilRefs, tileIDs, renderToTexture, false); + this._tileClippingMaskIDs = stencilRefs; + } + + _renderTileMasks(tileStencilRefs: {[_: string]: number}, tileIDs: Array, renderToTexture: boolean, useBorders: boolean) { + const context = this.context; + const gl = context.gl; + const projection = this.style.projection; + const transform = this.transform; + + const program = this.useProgram('clippingMask'); + + // 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 stencilRef = tileStencilRefs[tileID.key]; const terrainData = this.style.map.terrain && this.style.map.terrain.getTerrainData(tileID); + const mesh = projection.getMeshFromTileID(this.context, tileID.canonical, useBorders, true, 'stencil'); + + const projectionData = transform.getProjectionData(tileID); + 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); + new StencilMode({func: gl.ALWAYS, mask: 0}, stencilRef, 0xFF, gl.KEEP, gl.KEEP, gl.REPLACE), + ColorMode.disabled, renderToTexture ? CullFaceMode.disabled : CullFaceMode.backCCW, null, + terrainData, projectionData, '$clipping', mesh.vertexBuffer, + mesh.indexBuffer, mesh.segments); } } @@ -307,6 +357,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) { @@ -363,7 +448,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(); } @@ -401,7 +486,14 @@ export class Painter { this.renderLayer(this, sourceCaches[layer.source], layer, coords); } + // Execute offscreen GPU tasks of the projection manager + this.style.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 @@ -424,7 +516,7 @@ export class Painter { const sourceCache = sourceCaches[layer.source]; const coords = coordsAscending[layer.source]; - this._renderTileClippingMasks(layer, coords); + this._renderTileClippingMasks(layer, coords, false); this.renderLayer(this, sourceCache, layer, coords); } } @@ -444,10 +536,15 @@ export class Painter { // separate clipping masks const coords = (layer.type === 'symbol' ? coordsDescendingSymbol : coordsDescending)[layer.source]; - this._renderTileClippingMasks(layer, coordsAscending[layer.source]); + this._renderTileClippingMasks(layer, coordsAscending[layer.source], false); this.renderLayer(this, sourceCache, layer, coords); } + // Render atmosphere, only for Globe projection + if (this.style.projection.name === 'globe') { + drawAtmosphere(this, this.style.sky, this.style.light); + } + if (this.options.showTileBoundaries) { const selectedSource = selectDebugSource(this.style, this.transform.zoom); if (selectedSource) { @@ -479,7 +576,7 @@ export class Painter { // Update coords/depth-framebuffer on camera movement, or tile reloading let doUpdate = this.terrainFacilitator.dirty; doUpdate ||= requireExact ? !mat4.exactEquals(prevMatrix, currMatrix) : !mat4.equals(prevMatrix, currMatrix); - doUpdate ||= this.style.map.terrain.sourceCache.tilesAfterTime(this.terrainFacilitator.renderTime).length > 0; + doUpdate ||= this.style.map.terrain.sourceCache.anyTilesAfterTime(this.terrainFacilitator.renderTime); if (!doUpdate) { return; @@ -531,38 +628,6 @@ 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 - */ - 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; - } - saveTileTexture(texture: Texture) { const textures = this._tileTextures[texture.size[0]]; if (!textures) { @@ -590,12 +655,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.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, @@ -603,7 +687,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..1d3638547c 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,8 @@ 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 {projectionObjectToUniformMap, ProjectionPreludeUniformsType, projectionUniforms} from './program/projection_program'; +import type {ProjectionData} from '../geo/projection/projection_data'; export type DrawMode = WebGLRenderingContextBase['LINES'] | WebGLRenderingContextBase['TRIANGLES'] | WebGL2RenderingContext['LINE_STRIP']; @@ -39,20 +40,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 +61,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 +75,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 +146,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 +158,7 @@ export class Program { cullFaceMode: Readonly, uniformValues: UniformValues, terrain: TerrainData, + projectionData: ProjectionData, layerID: string, layoutVertexBuffer: VertexBuffer, indexBuffer: IndexBuffer, @@ -186,8 +191,17 @@ export class Program { } } - for (const name in this.fixedUniforms) { - this.fixedUniforms[name].set(uniformValues[name]); + if (projectionData) { + for (const fieldName in projectionData) { + const uniformName = projectionObjectToUniformMap[fieldName]; + this.projectionUniforms[uniformName].set(projectionData[fieldName]); + } + } + + if (uniformValues) { + for (const name in this.fixedUniforms) { + this.fixedUniforms[name].set(uniformValues[name]); + } } if (configuration) { diff --git a/src/render/program/atmosphere_program.ts b/src/render/program/atmosphere_program.ts new file mode 100644 index 0000000000..b0d4f301b5 --- /dev/null +++ b/src/render/program/atmosphere_program.ts @@ -0,0 +1,35 @@ +import type {Context} from '../../gl/context'; +import {UniformValues, UniformLocations, Uniform1f, Uniform3f, UniformMatrix4f} from '../uniform_binding'; +import {mat4, vec3} from 'gl-matrix'; + +export type atmosphereUniformsType = { + 'u_sun_pos': Uniform3f; + 'u_atmosphere_blend': Uniform1f; + 'u_globe_position': Uniform3f; + 'u_globe_radius': Uniform1f; + 'u_inv_proj_matrix': UniformMatrix4f; +}; + +const atmosphereUniforms = (context: Context, locations: UniformLocations): atmosphereUniformsType => ({ + 'u_sun_pos': new Uniform3f(context, locations.u_sun_pos), + 'u_atmosphere_blend': new Uniform1f(context, locations.u_atmosphere_blend), + 'u_globe_position': new Uniform3f(context, locations.u_globe_position), + 'u_globe_radius': new Uniform1f(context, locations.u_globe_radius), + 'u_inv_proj_matrix': new UniformMatrix4f(context, locations.u_inv_proj_matrix), +}); + +const atmosphereUniformValues = ( + sunPos: vec3, + atmosphereBlend: number, + globePosition: vec3, + globeRadius: number, + invProjMatrix: mat4, +): UniformValues => ({ + 'u_sun_pos': sunPos, + 'u_atmosphere_blend': atmosphereBlend, + 'u_globe_position': globePosition, + 'u_globe_radius': globeRadius, + 'u_inv_proj_matrix': invProjMatrix, +}); + +export {atmosphereUniforms, atmosphereUniformValues}; diff --git a/src/render/program/background_program.ts b/src/render/program/background_program.ts index 7802c488ba..53966d1c65 100644 --- a/src/render/program/background_program.ts +++ b/src/render/program/background_program.ts @@ -3,8 +3,7 @@ import { Uniform1i, Uniform1f, Uniform2f, - UniformColor, - UniformMatrix4f + UniformColor } from '../uniform_binding'; import {extend} from '../../util/util'; @@ -15,16 +14,13 @@ import type {Color, ResolvedImage} from '@maplibre/maplibre-gl-style-spec'; import type {CrossFaded} from '../../style/properties'; import type {CrossfadeParameters} from '../../style/evaluation_parameters'; import type {OverscaledTileID} from '../../source/tile_id'; -import {mat4} from 'gl-matrix'; export type BackgroundUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_opacity': Uniform1f; 'u_color': UniformColor; }; export type BackgroundPatternUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_opacity': Uniform1f; // pattern uniforms: 'u_image': Uniform1i; @@ -44,13 +40,11 @@ export type BackgroundPatternUniformsType = { }; const backgroundUniforms = (context: Context, locations: UniformLocations): BackgroundUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_opacity': new Uniform1f(context, locations.u_opacity), 'u_color': new UniformColor(context, locations.u_color) }); const backgroundPatternUniforms = (context: Context, locations: UniformLocations): BackgroundPatternUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_opacity': new Uniform1f(context, locations.u_opacity), 'u_image': new Uniform1i(context, locations.u_image), 'u_pattern_tl_a': new Uniform2f(context, locations.u_pattern_tl_a), @@ -68,14 +62,12 @@ const backgroundPatternUniforms = (context: Context, locations: UniformLocations 'u_tile_units_to_pixels': new Uniform1f(context, locations.u_tile_units_to_pixels) }); -const backgroundUniformValues = (matrix: mat4, opacity: number, color: Color): UniformValues => ({ - 'u_matrix': matrix, +const backgroundUniformValues = (opacity: number, color: Color): UniformValues => ({ 'u_opacity': opacity, 'u_color': color }); const backgroundPatternUniformValues = ( - matrix: mat4, opacity: number, painter: Painter, image: CrossFaded, @@ -87,7 +79,6 @@ const backgroundPatternUniformValues = ( ): UniformValues => extend( bgPatternUniformValues(image, crossfade, painter, tile), { - 'u_matrix': matrix, 'u_opacity': opacity } ); diff --git a/src/render/program/circle_program.ts b/src/render/program/circle_program.ts index 97f1ef043c..a23a4d81c8 100644 --- a/src/render/program/circle_program.ts +++ b/src/render/program/circle_program.ts @@ -1,12 +1,12 @@ -import {Uniform1i, Uniform1f, Uniform2f, UniformMatrix4f} from '../uniform_binding'; +import {Uniform1i, Uniform1f, Uniform2f} from '../uniform_binding'; import {pixelsToTileUnits} from '../../source/pixels_to_tile_units'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../uniform_binding'; -import type {OverscaledTileID} from '../../source/tile_id'; import type {Tile} from '../../source/tile'; import type {CircleStyleLayer} from '../../style/style_layer/circle_style_layer'; import type {Painter} from '../painter'; +import {EXTENT} from '../../data/extent'; export type CircleUniformsType = { 'u_camera_to_center_distance': Uniform1f; @@ -14,7 +14,8 @@ export type CircleUniformsType = { 'u_pitch_with_map': Uniform1i; 'u_extrude_scale': Uniform2f; 'u_device_pixel_ratio': Uniform1f; - 'u_matrix': UniformMatrix4f; + 'u_globe_extrude_scale': Uniform1f; + 'u_translate': Uniform2f; }; const circleUniforms = (context: Context, locations: UniformLocations): CircleUniformsType => ({ @@ -23,22 +24,29 @@ const circleUniforms = (context: Context, locations: UniformLocations): CircleUn 'u_pitch_with_map': new Uniform1i(context, locations.u_pitch_with_map), 'u_extrude_scale': new Uniform2f(context, locations.u_extrude_scale), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix) + 'u_globe_extrude_scale': new Uniform1f(context, locations.u_globe_extrude_scale), + 'u_translate': new Uniform2f(context, locations.u_translate), }); const circleUniformValues = ( painter: Painter, - coord: OverscaledTileID, tile: Tile, - layer: CircleStyleLayer + layer: CircleStyleLayer, + translate: [number, number], + radiusCorrectionFactor: number ): UniformValues => { const transform = painter.transform; let pitchWithMap: boolean, extrudeScale: [number, number]; + let globeExtrudeScale: number = 0; if (layer.paint.get('circle-pitch-alignment') === 'map') { const pixelRatio = pixelsToTileUnits(tile, 1, transform.zoom); pitchWithMap = true; extrudeScale = [pixelRatio, pixelRatio]; + + // For globe rendering we need to know how much to extrude the circle as an *angle*. + // The calculation: (one pixel in tile units) / (earth circumference in tile units) * (2PI radians) * radiusCorrectionFactor + globeExtrudeScale = pixelRatio / (EXTENT * Math.pow(2, tile.tileID.overscaledZ)) * 2.0 * Math.PI * radiusCorrectionFactor; } else { pitchWithMap = false; extrudeScale = transform.pixelsToGLUnits; @@ -47,14 +55,11 @@ const circleUniformValues = ( return { 'u_camera_to_center_distance': transform.cameraToCenterDistance, 'u_scale_with_map': +(layer.paint.get('circle-pitch-scale') === 'map'), - 'u_matrix': painter.translatePosMatrix( - coord.posMatrix, - tile, - layer.paint.get('circle-translate'), - layer.paint.get('circle-translate-anchor')), 'u_pitch_with_map': +(pitchWithMap), 'u_device_pixel_ratio': painter.pixelRatio, - 'u_extrude_scale': extrudeScale + 'u_extrude_scale': extrudeScale, + 'u_globe_extrude_scale': globeExtrudeScale, + 'u_translate': translate, }; }; 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/collision_program.ts b/src/render/program/collision_program.ts index e330562ae8..e27c367236 100644 --- a/src/render/program/collision_program.ts +++ b/src/render/program/collision_program.ts @@ -1,45 +1,32 @@ -import {Uniform1f, Uniform2f, UniformMatrix4f} from '../uniform_binding'; +import {Uniform2f} from '../uniform_binding'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../uniform_binding'; -import type {Transform} from '../../geo/transform'; -import {mat4} from 'gl-matrix'; +import type {IReadonlyTransform} from '../../geo/transform_interface'; export type CollisionUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_pixel_extrude_scale': Uniform2f; }; export type CollisionCircleUniformsType = { - 'u_matrix': UniformMatrix4f; - 'u_inv_matrix': UniformMatrix4f; - 'u_camera_to_center_distance': Uniform1f; 'u_viewport_size': Uniform2f; }; const collisionUniforms = (context: Context, locations: UniformLocations): CollisionUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_pixel_extrude_scale': new Uniform2f(context, locations.u_pixel_extrude_scale) }); const collisionCircleUniforms = (context: Context, locations: UniformLocations): CollisionCircleUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), - 'u_inv_matrix': new UniformMatrix4f(context, locations.u_inv_matrix), - 'u_camera_to_center_distance': new Uniform1f(context, locations.u_camera_to_center_distance), 'u_viewport_size': new Uniform2f(context, locations.u_viewport_size) }); -const collisionUniformValues = (transform: {width: number; height: number}, matrix: mat4): UniformValues => { +const collisionUniformValues = (transform: {width: number; height: number}): UniformValues => { return { - 'u_matrix': matrix, 'u_pixel_extrude_scale': [1.0 / transform.width, 1.0 / transform.height], }; }; -const collisionCircleUniformValues = (matrix: mat4, invMatrix: mat4, transform: Transform): UniformValues => { +const collisionCircleUniformValues = (transform: IReadonlyTransform): UniformValues => { return { - 'u_matrix': matrix, - 'u_inv_matrix': invMatrix, - 'u_camera_to_center_distance': transform.cameraToCenterDistance, 'u_viewport_size': [transform.width, transform.height] }; }; diff --git a/src/render/program/debug_program.ts b/src/render/program/debug_program.ts index 21113eedd4..2f79064386 100644 --- a/src/render/program/debug_program.ts +++ b/src/render/program/debug_program.ts @@ -1,26 +1,22 @@ -import {UniformColor, UniformMatrix4f, Uniform1i, Uniform1f} from '../uniform_binding'; +import {UniformColor, Uniform1i, Uniform1f} from '../uniform_binding'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../uniform_binding'; import type {Color} from '@maplibre/maplibre-gl-style-spec'; -import {mat4} from 'gl-matrix'; export type DebugUniformsType = { 'u_color': UniformColor; - 'u_matrix': UniformMatrix4f; 'u_overlay': Uniform1i; 'u_overlay_scale': Uniform1f; }; const debugUniforms = (context: Context, locations: UniformLocations): DebugUniformsType => ({ 'u_color': new UniformColor(context, locations.u_color), - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_overlay': new Uniform1i(context, locations.u_overlay), 'u_overlay_scale': new Uniform1f(context, locations.u_overlay_scale) }); -const debugUniformValues = (matrix: mat4, color: Color, scaleRatio: number = 1): UniformValues => ({ - 'u_matrix': matrix, +const debugUniformValues = (color: Color, scaleRatio: number = 1): UniformValues => ({ 'u_color': color, 'u_overlay': 0, 'u_overlay_scale': scaleRatio diff --git a/src/render/program/fill_extrusion_program.ts b/src/render/program/fill_extrusion_program.ts index d6efae422a..d600bca5a0 100644 --- a/src/render/program/fill_extrusion_program.ts +++ b/src/render/program/fill_extrusion_program.ts @@ -3,11 +3,10 @@ import { Uniform1i, Uniform1f, Uniform2f, - Uniform3f, - UniformMatrix4f + Uniform3f } from '../uniform_binding'; -import {mat3, mat4, vec3} from 'gl-matrix'; +import {mat3, vec3} from 'gl-matrix'; import {extend} from '../../util/util'; import type {Context} from '../../gl/context'; @@ -18,21 +17,26 @@ import type {CrossfadeParameters} from '../../style/evaluation_parameters'; import type {Tile} from '../../source/tile'; export type FillExtrusionUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_lightpos': Uniform3f; + 'u_lightpos_globe': Uniform3f; 'u_lightintensity': Uniform1f; 'u_lightcolor': Uniform3f; 'u_vertical_gradient': Uniform1f; 'u_opacity': Uniform1f; + 'u_fill_translate': Uniform2f; + 'u_camera_pos_globe': Uniform3f; }; export type FillExtrusionPatternUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_lightpos': Uniform3f; + 'u_lightpos_globe': Uniform3f; 'u_lightintensity': Uniform1f; 'u_lightcolor': Uniform3f; 'u_height_factor': Uniform1f; 'u_vertical_gradient': Uniform1f; + 'u_opacity': Uniform1f; + 'u_fill_translate': Uniform2f; + 'u_camera_pos_globe': Uniform3f; // pattern uniforms: 'u_texsize': Uniform2f; 'u_image': Uniform1i; @@ -40,40 +44,44 @@ export type FillExtrusionPatternUniformsType = { 'u_pixel_coord_lower': Uniform2f; 'u_scale': Uniform3f; 'u_fade': Uniform1f; - 'u_opacity': Uniform1f; }; const fillExtrusionUniforms = (context: Context, locations: UniformLocations): FillExtrusionUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_lightpos': new Uniform3f(context, locations.u_lightpos), + 'u_lightpos_globe': new Uniform3f(context, locations.u_lightpos_globe), 'u_lightintensity': new Uniform1f(context, locations.u_lightintensity), 'u_lightcolor': new Uniform3f(context, locations.u_lightcolor), 'u_vertical_gradient': new Uniform1f(context, locations.u_vertical_gradient), - 'u_opacity': new Uniform1f(context, locations.u_opacity) + 'u_opacity': new Uniform1f(context, locations.u_opacity), + 'u_fill_translate': new Uniform2f(context, locations.u_fill_translate), + 'u_camera_pos_globe': new Uniform3f(context, locations.u_camera_pos_globe) }); const fillExtrusionPatternUniforms = (context: Context, locations: UniformLocations): FillExtrusionPatternUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_lightpos': new Uniform3f(context, locations.u_lightpos), + 'u_lightpos_globe': new Uniform3f(context, locations.u_lightpos_globe), 'u_lightintensity': new Uniform1f(context, locations.u_lightintensity), 'u_lightcolor': new Uniform3f(context, locations.u_lightcolor), 'u_vertical_gradient': new Uniform1f(context, locations.u_vertical_gradient), 'u_height_factor': new Uniform1f(context, locations.u_height_factor), + 'u_opacity': new Uniform1f(context, locations.u_opacity), + 'u_fill_translate': new Uniform2f(context, locations.u_fill_translate), + 'u_camera_pos_globe': new Uniform3f(context, locations.u_camera_pos_globe), // pattern uniforms 'u_image': new Uniform1i(context, locations.u_image), 'u_texsize': new Uniform2f(context, locations.u_texsize), 'u_pixel_coord_upper': new Uniform2f(context, locations.u_pixel_coord_upper), 'u_pixel_coord_lower': new Uniform2f(context, locations.u_pixel_coord_lower), 'u_scale': new Uniform3f(context, locations.u_scale), - 'u_fade': new Uniform1f(context, locations.u_fade), - 'u_opacity': new Uniform1f(context, locations.u_opacity) + 'u_fade': new Uniform1f(context, locations.u_fade) }); const fillExtrusionUniformValues = ( - matrix: mat4, painter: Painter, shouldUseVerticalGradient: boolean, - opacity: number + opacity: number, + translate: [number, number], + cameraPosGlobe: vec3 ): UniformValues => { const light = painter.style.light; const _lp = light.properties.get('position'); @@ -83,29 +91,33 @@ const fillExtrusionUniformValues = ( mat3.fromRotation(lightMat, -painter.transform.angle); } vec3.transformMat3(lightPos, lightPos, lightMat); + const transformedLightPos = painter.transform.transformLightDirection(lightPos); const lightColor = light.properties.get('color'); return { - 'u_matrix': matrix, 'u_lightpos': lightPos, + 'u_lightpos_globe': transformedLightPos, 'u_lightintensity': light.properties.get('intensity'), 'u_lightcolor': [lightColor.r, lightColor.g, lightColor.b], 'u_vertical_gradient': +shouldUseVerticalGradient, - 'u_opacity': opacity + 'u_opacity': opacity, + 'u_fill_translate': translate, + 'u_camera_pos_globe': cameraPosGlobe, }; }; const fillExtrusionPatternUniformValues = ( - matrix: mat4, painter: Painter, shouldUseVerticalGradient: boolean, opacity: number, + translate: [number, number], + cameraPosGlobe: vec3, coord: OverscaledTileID, crossfade: CrossfadeParameters, tile: Tile ): UniformValues => { - return extend(fillExtrusionUniformValues(matrix, painter, shouldUseVerticalGradient, opacity), + return extend(fillExtrusionUniformValues(painter, shouldUseVerticalGradient, opacity, translate, cameraPosGlobe), patternUniformValues(crossfade, painter, tile), { 'u_height_factor': -Math.pow(2, coord.overscaledZ) / tile.tileSize / 8 diff --git a/src/render/program/fill_program.ts b/src/render/program/fill_program.ts index 1ae148ba1f..471ad4bea9 100644 --- a/src/render/program/fill_program.ts +++ b/src/render/program/fill_program.ts @@ -4,7 +4,6 @@ import { Uniform1f, Uniform2f, Uniform3f, - UniformMatrix4f } from '../uniform_binding'; import {extend} from '../../util/util'; @@ -13,19 +12,17 @@ import type {UniformValues, UniformLocations} from '../uniform_binding'; import type {Context} from '../../gl/context'; import type {CrossfadeParameters} from '../../style/evaluation_parameters'; import type {Tile} from '../../source/tile'; -import {mat4} from 'gl-matrix'; export type FillUniformsType = { - 'u_matrix': UniformMatrix4f; + 'u_fill_translate': Uniform2f; }; export type FillOutlineUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_world': Uniform2f; + 'u_fill_translate': Uniform2f; }; export type FillPatternUniformsType = { - 'u_matrix': UniformMatrix4f; // pattern uniforms: 'u_texsize': Uniform2f; 'u_image': Uniform1i; @@ -33,10 +30,10 @@ export type FillPatternUniformsType = { 'u_pixel_coord_lower': Uniform2f; 'u_scale': Uniform3f; 'u_fade': Uniform1f; + 'u_fill_translate': Uniform2f; }; export type FillOutlinePatternUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_world': Uniform2f; // pattern uniforms: 'u_texsize': Uniform2f; @@ -45,65 +42,68 @@ export type FillOutlinePatternUniformsType = { 'u_pixel_coord_lower': Uniform2f; 'u_scale': Uniform3f; 'u_fade': Uniform1f; + 'u_fill_translate': Uniform2f; }; const fillUniforms = (context: Context, locations: UniformLocations): FillUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix) + 'u_fill_translate': new Uniform2f(context, locations.u_fill_translate) }); const fillPatternUniforms = (context: Context, locations: UniformLocations): FillPatternUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_image': new Uniform1i(context, locations.u_image), 'u_texsize': new Uniform2f(context, locations.u_texsize), 'u_pixel_coord_upper': new Uniform2f(context, locations.u_pixel_coord_upper), 'u_pixel_coord_lower': new Uniform2f(context, locations.u_pixel_coord_lower), 'u_scale': new Uniform3f(context, locations.u_scale), - 'u_fade': new Uniform1f(context, locations.u_fade) + 'u_fade': new Uniform1f(context, locations.u_fade), + 'u_fill_translate': new Uniform2f(context, locations.u_fill_translate) }); const fillOutlineUniforms = (context: Context, locations: UniformLocations): FillOutlineUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), - 'u_world': new Uniform2f(context, locations.u_world) + 'u_world': new Uniform2f(context, locations.u_world), + 'u_fill_translate': new Uniform2f(context, locations.u_fill_translate) }); const fillOutlinePatternUniforms = (context: Context, locations: UniformLocations): FillOutlinePatternUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_world': new Uniform2f(context, locations.u_world), 'u_image': new Uniform1i(context, locations.u_image), 'u_texsize': new Uniform2f(context, locations.u_texsize), 'u_pixel_coord_upper': new Uniform2f(context, locations.u_pixel_coord_upper), 'u_pixel_coord_lower': new Uniform2f(context, locations.u_pixel_coord_lower), 'u_scale': new Uniform3f(context, locations.u_scale), - 'u_fade': new Uniform1f(context, locations.u_fade) -}); - -const fillUniformValues = (matrix: mat4): UniformValues => ({ - 'u_matrix': matrix + 'u_fade': new Uniform1f(context, locations.u_fade), + 'u_fill_translate': new Uniform2f(context, locations.u_fill_translate) }); const fillPatternUniformValues = ( - matrix: mat4, painter: Painter, crossfade: CrossfadeParameters, - tile: Tile + tile: Tile, + translate: [number, number] ): UniformValues => extend( - fillUniformValues(matrix), - patternUniformValues(crossfade, painter, tile) + patternUniformValues(crossfade, painter, tile), + { + 'u_fill_translate': translate, + } ); -const fillOutlineUniformValues = (matrix: mat4, drawingBufferSize: [number, number]): UniformValues => ({ - 'u_matrix': matrix, - 'u_world': drawingBufferSize +const fillUniformValues = (translate: [number, number]): UniformValues => ({ + 'u_fill_translate': translate, +}); + +const fillOutlineUniformValues = (drawingBufferSize: [number, number], translate: [number, number]): UniformValues => ({ + 'u_world': drawingBufferSize, + 'u_fill_translate': translate, }); const fillOutlinePatternUniformValues = ( - matrix: mat4, painter: Painter, crossfade: CrossfadeParameters, tile: Tile, - drawingBufferSize: [number, number] + drawingBufferSize: [number, number], + translate: [number, number] ): UniformValues => extend( - fillPatternUniformValues(matrix, painter, crossfade, tile), + fillPatternUniformValues(painter, crossfade, tile, translate), { 'u_world': drawingBufferSize } diff --git a/src/render/program/heatmap_program.ts b/src/render/program/heatmap_program.ts index b3c4e02a41..6222db1b9b 100644 --- a/src/render/program/heatmap_program.ts +++ b/src/render/program/heatmap_program.ts @@ -13,11 +13,12 @@ import type {Tile} from '../../source/tile'; import type {UniformValues, UniformLocations} from '../uniform_binding'; import type {Painter} from '../painter'; import type {HeatmapStyleLayer} from '../../style/style_layer/heatmap_style_layer'; +import {EXTENT} from '../../data/extent'; export type HeatmapUniformsType = { 'u_extrude_scale': Uniform1f; 'u_intensity': Uniform1f; - 'u_matrix': UniformMatrix4f; + 'u_globe_extrude_scale': Uniform1f; }; export type HeatmapTextureUniformsType = { @@ -31,7 +32,7 @@ export type HeatmapTextureUniformsType = { const heatmapUniforms = (context: Context, locations: UniformLocations): HeatmapUniformsType => ({ 'u_extrude_scale': new Uniform1f(context, locations.u_extrude_scale), 'u_intensity': new Uniform1f(context, locations.u_intensity), - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix) + 'u_globe_extrude_scale': new Uniform1f(context, locations.u_globe_extrude_scale) }); const heatmapTextureUniforms = (context: Context, locations: UniformLocations): HeatmapTextureUniformsType => ({ @@ -42,11 +43,16 @@ const heatmapTextureUniforms = (context: Context, locations: UniformLocations): 'u_opacity': new Uniform1f(context, locations.u_opacity) }); -const heatmapUniformValues = (matrix: mat4, tile: Tile, zoom: number, intensity: number): UniformValues => ({ - 'u_matrix': matrix, - 'u_extrude_scale': pixelsToTileUnits(tile, 1, zoom), - 'u_intensity': intensity -}); +const heatmapUniformValues = (tile: Tile, zoom: number, intensity: number, radiusCorrectionFactor: number): UniformValues => { + const pixelRatio = pixelsToTileUnits(tile, 1, zoom); + // See comment in circle_program.ts + const globeExtrudeScale = pixelRatio / (EXTENT * Math.pow(2, tile.tileID.overscaledZ)) * 2.0 * Math.PI * radiusCorrectionFactor; + return { + 'u_extrude_scale': pixelsToTileUnits(tile, 1, zoom), + 'u_intensity': intensity, + 'u_globe_extrude_scale': globeExtrudeScale + }; +}; const heatmapTextureUniformValues = ( painter: Painter, diff --git a/src/render/program/hillshade_program.ts b/src/render/program/hillshade_program.ts index f7f89aeb74..32ea9b4d6c 100644 --- a/src/render/program/hillshade_program.ts +++ b/src/render/program/hillshade_program.ts @@ -20,7 +20,6 @@ import type {DEMData} from '../../data/dem_data'; import type {OverscaledTileID} from '../../source/tile_id'; export type HillshadeUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_image': Uniform1i; 'u_latrange': Uniform2f; 'u_light': Uniform2f; @@ -38,7 +37,6 @@ export type HillshadePrepareUniformsType = { }; const hillshadeUniforms = (context: Context, locations: UniformLocations): HillshadeUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_image': new Uniform1i(context, locations.u_image), 'u_latrange': new Uniform2f(context, locations.u_latrange), 'u_light': new Uniform2f(context, locations.u_light), @@ -59,7 +57,6 @@ const hillshadeUniformValues = ( painter: Painter, tile: Tile, layer: HillshadeStyleLayer, - coord: OverscaledTileID ): UniformValues => { const shadow = layer.paint.get('hillshade-shadow-color'); const highlight = layer.paint.get('hillshade-highlight-color'); @@ -70,9 +67,7 @@ const hillshadeUniformValues = ( if (layer.paint.get('hillshade-illumination-anchor') === 'viewport') { azimuthal -= painter.transform.angle; } - const align = !painter.options.moving; return { - 'u_matrix': coord ? coord.posMatrix : painter.transform.calculatePosMatrix(tile.tileID.toUnwrapped(), align), 'u_image': 0, 'u_latrange': getTileLatRange(painter, tile.tileID), 'u_light': [layer.paint.get('hillshade-exaggeration'), azimuthal], diff --git a/src/render/program/line_program.ts b/src/render/program/line_program.ts index ac33493d67..b063878f26 100644 --- a/src/render/program/line_program.ts +++ b/src/render/program/line_program.ts @@ -1,26 +1,25 @@ -import {Uniform1i, Uniform1f, Uniform2f, Uniform3f, UniformMatrix4f} from '../uniform_binding'; +import {Uniform1i, Uniform1f, Uniform2f, Uniform3f} from '../uniform_binding'; import {pixelsToTileUnits} from '../../source/pixels_to_tile_units'; -import {extend} from '../../util/util'; +import {extend, translatePosition} from '../../util/util'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../uniform_binding'; -import type {Transform} from '../../geo/transform'; +import type {IReadonlyTransform} from '../../geo/transform_interface'; import type {Tile} from '../../source/tile'; import type {CrossFaded} from '../../style/properties'; import type {LineStyleLayer} from '../../style/style_layer/line_style_layer'; import type {Painter} from '../painter'; import type {CrossfadeParameters} from '../../style/evaluation_parameters'; -import {OverscaledTileID} from '../../source/tile_id'; export type LineUniformsType = { - 'u_matrix': UniformMatrix4f; + 'u_translation': Uniform2f; 'u_ratio': Uniform1f; 'u_device_pixel_ratio': Uniform1f; 'u_units_to_pixels': Uniform2f; }; export type LineGradientUniformsType = { - 'u_matrix': UniformMatrix4f; + 'u_translation': Uniform2f; 'u_ratio': Uniform1f; 'u_device_pixel_ratio': Uniform1f; 'u_units_to_pixels': Uniform2f; @@ -29,7 +28,7 @@ export type LineGradientUniformsType = { }; export type LinePatternUniformsType = { - 'u_matrix': UniformMatrix4f; + 'u_translation': Uniform2f; 'u_texsize': Uniform2f; 'u_ratio': Uniform1f; 'u_device_pixel_ratio': Uniform1f; @@ -40,7 +39,7 @@ export type LinePatternUniformsType = { }; export type LineSDFUniformsType = { - 'u_matrix': UniformMatrix4f; + 'u_translation': Uniform2f; 'u_ratio': Uniform1f; 'u_device_pixel_ratio': Uniform1f; 'u_units_to_pixels': Uniform2f; @@ -54,14 +53,14 @@ export type LineSDFUniformsType = { }; const lineUniforms = (context: Context, locations: UniformLocations): LineUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), + 'u_translation': new Uniform2f(context, locations.u_translation), 'u_ratio': new Uniform1f(context, locations.u_ratio), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), 'u_units_to_pixels': new Uniform2f(context, locations.u_units_to_pixels) }); const lineGradientUniforms = (context: Context, locations: UniformLocations): LineGradientUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), + 'u_translation': new Uniform2f(context, locations.u_translation), 'u_ratio': new Uniform1f(context, locations.u_ratio), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), 'u_units_to_pixels': new Uniform2f(context, locations.u_units_to_pixels), @@ -70,7 +69,7 @@ const lineGradientUniforms = (context: Context, locations: UniformLocations): Li }); const linePatternUniforms = (context: Context, locations: UniformLocations): LinePatternUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), + 'u_translation': new Uniform2f(context, locations.u_translation), 'u_texsize': new Uniform2f(context, locations.u_texsize), 'u_ratio': new Uniform1f(context, locations.u_ratio), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), @@ -81,7 +80,7 @@ const linePatternUniforms = (context: Context, locations: UniformLocations): Lin }); const lineSDFUniforms = (context: Context, locations: UniformLocations): LineSDFUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), + 'u_translation': new Uniform2f(context, locations.u_translation), 'u_ratio': new Uniform1f(context, locations.u_ratio), 'u_device_pixel_ratio': new Uniform1f(context, locations.u_device_pixel_ratio), 'u_units_to_pixels': new Uniform2f(context, locations.u_units_to_pixels), @@ -98,13 +97,13 @@ const lineUniformValues = ( painter: Painter, tile: Tile, layer: LineStyleLayer, - coord: OverscaledTileID + ratioScale: number, ): UniformValues => { const transform = painter.transform; return { - 'u_matrix': calculateMatrix(painter, tile, layer, coord), - 'u_ratio': 1 / pixelsToTileUnits(tile, 1, transform.zoom), + 'u_translation': calculateTranslation(painter, tile, layer), + 'u_ratio': ratioScale / pixelsToTileUnits(tile, 1, transform.zoom), 'u_device_pixel_ratio': painter.pixelRatio, 'u_units_to_pixels': [ 1 / transform.pixelsToGLUnits[0], @@ -117,10 +116,10 @@ const lineGradientUniformValues = ( painter: Painter, tile: Tile, layer: LineStyleLayer, + ratioScale: number, imageHeight: number, - coord: OverscaledTileID ): UniformValues => { - return extend(lineUniformValues(painter, tile, layer, coord), { + return extend(lineUniformValues(painter, tile, layer, ratioScale), { 'u_image': 0, 'u_image_height': imageHeight, }); @@ -130,16 +129,16 @@ const linePatternUniformValues = ( painter: Painter, tile: Tile, layer: LineStyleLayer, + ratioScale: number, crossfade: CrossfadeParameters, - coord: OverscaledTileID ): UniformValues => { const transform = painter.transform; const tileZoomRatio = calculateTileRatio(tile, transform); return { - 'u_matrix': calculateMatrix(painter, tile, layer, coord), + 'u_translation': calculateTranslation(painter, tile, layer), 'u_texsize': tile.imageAtlasTexture.size, // camera zoom ratio - 'u_ratio': 1 / pixelsToTileUnits(tile, 1, transform.zoom), + 'u_ratio': ratioScale / pixelsToTileUnits(tile, 1, transform.zoom), 'u_device_pixel_ratio': painter.pixelRatio, 'u_image': 0, 'u_scale': [tileZoomRatio, crossfade.fromScale, crossfade.toScale], @@ -155,9 +154,9 @@ const lineSDFUniformValues = ( painter: Painter, tile: Tile, layer: LineStyleLayer, + ratioScale: number, dasharray: CrossFaded>, crossfade: CrossfadeParameters, - coord: OverscaledTileID ): UniformValues => { const transform = painter.transform; const lineAtlas = painter.lineAtlas; @@ -171,7 +170,7 @@ const lineSDFUniformValues = ( const widthA = posA.width * crossfade.fromScale; const widthB = posB.width * crossfade.toScale; - return extend(lineUniformValues(painter, tile, layer, coord), { + return extend(lineUniformValues(painter, tile, layer, ratioScale), { 'u_patternscale_a': [tileRatio / widthA, -posA.height / 2], 'u_patternscale_b': [tileRatio / widthB, -posB.height / 2], 'u_sdfgamma': lineAtlas.width / (Math.min(widthA, widthB) * 256 * painter.pixelRatio) / 2, @@ -182,13 +181,14 @@ const lineSDFUniformValues = ( }); }; -function calculateTileRatio(tile: Tile, transform: Transform) { +function calculateTileRatio(tile: Tile, transform: IReadonlyTransform) { return 1 / pixelsToTileUnits(tile, 1, transform.tileZoom); } -function calculateMatrix(painter: Painter, tile: Tile, layer: LineStyleLayer, coord: OverscaledTileID) { - return painter.translatePosMatrix( - coord ? coord.posMatrix : tile.tileID.posMatrix, +function calculateTranslation(painter: Painter, tile: Tile, layer: LineStyleLayer): [number, number] { + // Translate line points prior to any transformation + return translatePosition( + painter.transform, tile, layer.paint.get('line-translate'), layer.paint.get('line-translate-anchor') diff --git a/src/render/program/program_uniforms.ts b/src/render/program/program_uniforms.ts index a080bb9578..69ee5580c4 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,8 +10,12 @@ 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'; +import {atmosphereUniforms} from './atmosphere_program'; import {skyUniforms} from './sky_program'; +const emptyUniforms = (_: any, __: any): any => {}; + export const programUniforms = { fillExtrusion: fillExtrusionUniforms, fillExtrusionPattern: fillExtrusionPatternUniforms, @@ -24,7 +27,7 @@ export const programUniforms = { collisionBox: collisionUniforms, collisionCircle: collisionCircleUniforms, debug: debugUniforms, - clippingMask: clippingMaskUniforms, + clippingMask: emptyUniforms, heatmap: heatmapUniforms, heatmapTexture: heatmapTextureUniforms, hillshade: hillshadeUniforms, @@ -42,5 +45,7 @@ export const programUniforms = { terrain: terrainUniforms, terrainDepth: terrainDepthUniforms, terrainCoords: terrainCoordsUniforms, + projectionErrorMeasurement: projectionErrorMeasurementUniforms, + atmosphere: atmosphereUniforms, sky: skyUniforms }; 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..d11f6e9e3e --- /dev/null +++ b/src/render/program/projection_program.ts @@ -0,0 +1,32 @@ +import {Uniform1f, Uniform4f, UniformLocations, UniformMatrix4f} from '../uniform_binding'; +import {Context} from '../../gl/context'; +// This next import is needed for the "@link" in the documentation to work properly. + +import type {ProjectionData} from '../../geo/projection/projection_data'; + +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), +}); + +/** + * Maps a field name in {@link ProjectionData} to its corresponding uniform name in {@link ProjectionPreludeUniformsType}. + */ +export const projectionObjectToUniformMap: {[field in keyof ProjectionData]: keyof ProjectionPreludeUniformsType} = { + mainMatrix: 'u_projection_matrix', + tileMercatorCoords: 'u_projection_tile_mercator_coords', + clippingPlane: 'u_projection_clipping_plane', + projectionTransition: 'u_projection_transition', + fallbackMatrix: 'u_projection_fallback_matrix', +}; 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/program/sky_program.ts b/src/render/program/sky_program.ts index 18457ca5b5..524b67fc48 100644 --- a/src/render/program/sky_program.ts +++ b/src/render/program/sky_program.ts @@ -1,8 +1,9 @@ import {UniformColor, Uniform1f} from '../uniform_binding'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../uniform_binding'; -import {Transform} from '../../geo/transform'; +import {IReadonlyTransform} from '../../geo/transform_interface'; import {Sky} from '../../style/sky'; +import {getMercatorHorizon} from '../../geo/projection/mercator_utils'; export type SkyUniformsType = { 'u_sky_color': UniformColor; @@ -18,10 +19,10 @@ const skyUniforms = (context: Context, locations: UniformLocations): SkyUniforms 'u_sky_horizon_blend': new Uniform1f(context, locations.u_sky_horizon_blend), }); -const skyUniformValues = (sky: Sky, transform: Transform, pixelRatio: number): UniformValues => ({ +const skyUniformValues = (sky: Sky, transform: IReadonlyTransform, pixelRatio: number): UniformValues => ({ 'u_sky_color': sky.properties.get('sky-color'), 'u_horizon_color': sky.properties.get('horizon-color'), - 'u_horizon': (transform.height / 2 + transform.getHorizon()) * pixelRatio, + 'u_horizon': (transform.height / 2 + getMercatorHorizon(transform)) * pixelRatio, 'u_sky_horizon_blend': (sky.properties.get('sky-horizon-blend') * transform.height / 2) * pixelRatio, }); diff --git a/src/render/program/symbol_program.ts b/src/render/program/symbol_program.ts index 3e8f8a56ef..cfe6d65e00 100644 --- a/src/render/program/symbol_program.ts +++ b/src/render/program/symbol_program.ts @@ -16,7 +16,6 @@ export type SymbolIconUniformsType = { 'u_rotate_symbol': Uniform1i; 'u_aspect_ratio': Uniform1f; 'u_fade_change': Uniform1f; - 'u_matrix': UniformMatrix4f; 'u_label_plane_matrix': UniformMatrix4f; 'u_coord_matrix': UniformMatrix4f; 'u_is_text': Uniform1i; @@ -39,7 +38,6 @@ export type SymbolSDFUniformsType = { 'u_rotate_symbol': Uniform1i; 'u_aspect_ratio': Uniform1f; 'u_fade_change': Uniform1f; - 'u_matrix': UniformMatrix4f; 'u_label_plane_matrix': UniformMatrix4f; 'u_coord_matrix': UniformMatrix4f; 'u_is_text': Uniform1i; @@ -65,7 +63,6 @@ export type symbolTextAndIconUniformsType = { 'u_rotate_symbol': Uniform1i; 'u_aspect_ratio': Uniform1f; 'u_fade_change': Uniform1f; - 'u_matrix': UniformMatrix4f; 'u_label_plane_matrix': UniformMatrix4f; 'u_coord_matrix': UniformMatrix4f; 'u_is_text': Uniform1i; @@ -93,7 +90,6 @@ const symbolIconUniforms = (context: Context, locations: UniformLocations): Symb 'u_rotate_symbol': new Uniform1i(context, locations.u_rotate_symbol), 'u_aspect_ratio': new Uniform1f(context, locations.u_aspect_ratio), 'u_fade_change': new Uniform1f(context, locations.u_fade_change), - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_label_plane_matrix': new UniformMatrix4f(context, locations.u_label_plane_matrix), 'u_coord_matrix': new UniformMatrix4f(context, locations.u_coord_matrix), 'u_is_text': new Uniform1i(context, locations.u_is_text), @@ -116,7 +112,6 @@ const symbolSDFUniforms = (context: Context, locations: UniformLocations): Symbo 'u_rotate_symbol': new Uniform1i(context, locations.u_rotate_symbol), 'u_aspect_ratio': new Uniform1f(context, locations.u_aspect_ratio), 'u_fade_change': new Uniform1f(context, locations.u_fade_change), - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_label_plane_matrix': new UniformMatrix4f(context, locations.u_label_plane_matrix), 'u_coord_matrix': new UniformMatrix4f(context, locations.u_coord_matrix), 'u_is_text': new Uniform1i(context, locations.u_is_text), @@ -142,7 +137,6 @@ const symbolTextAndIconUniforms = (context: Context, locations: UniformLocations 'u_rotate_symbol': new Uniform1i(context, locations.u_rotate_symbol), 'u_aspect_ratio': new Uniform1f(context, locations.u_aspect_ratio), 'u_fade_change': new Uniform1f(context, locations.u_fade_change), - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_label_plane_matrix': new UniformMatrix4f(context, locations.u_label_plane_matrix), 'u_coord_matrix': new UniformMatrix4f(context, locations.u_coord_matrix), 'u_is_text': new Uniform1i(context, locations.u_is_text), @@ -171,7 +165,6 @@ const symbolIconUniformValues = ( isAlongLine: boolean, isVariableAnchor: boolean, painter: Painter, - matrix: mat4, labelPlaneMatrix: mat4, glCoordMatrix: mat4, translation: [number, number], @@ -191,7 +184,6 @@ const symbolIconUniformValues = ( 'u_rotate_symbol': +rotateInShader, 'u_aspect_ratio': transform.width / transform.height, 'u_fade_change': painter.options.fadeDuration ? painter.symbolFadeChange : 1, - 'u_matrix': matrix, 'u_label_plane_matrix': labelPlaneMatrix, 'u_coord_matrix': glCoordMatrix, 'u_is_text': +isText, @@ -216,7 +208,6 @@ const symbolSDFUniformValues = ( isAlongLine: boolean, isVariableAnchor: boolean, painter: Painter, - matrix: mat4, labelPlaneMatrix: mat4, glCoordMatrix: mat4, translation: [number, number], @@ -228,9 +219,9 @@ const symbolSDFUniformValues = ( const transform = painter.transform; return extend(symbolIconUniformValues(functionType, size, - rotateInShader, pitchWithMap, isAlongLine, isVariableAnchor, painter, matrix, labelPlaneMatrix, + rotateInShader, pitchWithMap, isAlongLine, isVariableAnchor, painter, labelPlaneMatrix, glCoordMatrix, translation, isText, texSize, pitchedScale), { - 'u_gamma_scale': (pitchWithMap ? Math.cos(transform._pitch) * transform.cameraToCenterDistance : 1), + 'u_gamma_scale': (pitchWithMap ? Math.cos(transform.pitch * Math.PI / 180.0) * transform.cameraToCenterDistance : 1), 'u_device_pixel_ratio': painter.pixelRatio, 'u_is_halo': +isHalo }); @@ -247,7 +238,6 @@ const symbolTextAndIconUniformValues = ( isAlongLine: boolean, isVariableAnchor: boolean, painter: Painter, - matrix: mat4, labelPlaneMatrix: mat4, glCoordMatrix: mat4, translation: [number, number], @@ -256,7 +246,7 @@ const symbolTextAndIconUniformValues = ( pitchedScale: number ): UniformValues => { return extend(symbolSDFUniformValues(functionType, size, - rotateInShader, pitchWithMap, isAlongLine, isVariableAnchor, painter, matrix, labelPlaneMatrix, + rotateInShader, pitchWithMap, isAlongLine, isVariableAnchor, painter, labelPlaneMatrix, glCoordMatrix, translation, true, texSizeSDF, true, pitchedScale), { 'u_texsize_icon': texSizeIcon, 'u_texture_icon': 1 diff --git a/src/render/program/terrain_program.ts b/src/render/program/terrain_program.ts index f6275e0927..75aa10750c 100644 --- a/src/render/program/terrain_program.ts +++ b/src/render/program/terrain_program.ts @@ -7,9 +7,9 @@ import { } from '../uniform_binding'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../../render/uniform_binding'; -import {mat4} from 'gl-matrix'; import {Sky} from '../../style/sky'; import {Color} from '@maplibre/maplibre-gl-style-spec'; +import {mat4} from 'gl-matrix'; export type TerrainPreludeUniformsType = { 'u_depth': Uniform1i; @@ -21,7 +21,6 @@ export type TerrainPreludeUniformsType = { }; export type TerrainUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_texture': Uniform1i; 'u_ele_delta': Uniform1f; 'u_fog_matrix': UniformMatrix4f; @@ -33,12 +32,10 @@ export type TerrainUniformsType = { }; export type TerrainDepthUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_ele_delta': Uniform1f; }; export type TerrainCoordsUniformsType = { - 'u_matrix': UniformMatrix4f; 'u_texture': Uniform1i; 'u_terrain_coords_id': Uniform1f; 'u_ele_delta': Uniform1f; @@ -54,7 +51,6 @@ const terrainPreludeUniforms = (context: Context, locations: UniformLocations): }); const terrainUniforms = (context: Context, locations: UniformLocations): TerrainUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_texture': new Uniform1i(context, locations.u_texture), 'u_ele_delta': new Uniform1f(context, locations.u_ele_delta), 'u_fog_matrix': new UniformMatrix4f(context, locations.u_fog_matrix), @@ -66,24 +62,20 @@ const terrainUniforms = (context: Context, locations: UniformLocations): Terrain }); const terrainDepthUniforms = (context: Context, locations: UniformLocations): TerrainDepthUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_ele_delta': new Uniform1f(context, locations.u_ele_delta) }); const terrainCoordsUniforms = (context: Context, locations: UniformLocations): TerrainCoordsUniformsType => ({ - 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), 'u_texture': new Uniform1i(context, locations.u_texture), 'u_terrain_coords_id': new Uniform1f(context, locations.u_terrain_coords_id), 'u_ele_delta': new Uniform1f(context, locations.u_ele_delta) }); const terrainUniformValues = ( - matrix: mat4, eleDelta: number, fogMatrix: mat4, sky: Sky, pitch: number): UniformValues => ({ - 'u_matrix': matrix, 'u_texture': 0, 'u_ele_delta': eleDelta, 'u_fog_matrix': fogMatrix, @@ -95,19 +87,15 @@ const terrainUniformValues = ( }); const terrainDepthUniformValues = ( - matrix: mat4, eleDelta: number ): UniformValues => ({ - 'u_matrix': matrix, 'u_ele_delta': eleDelta }); const terrainCoordsUniformValues = ( - matrix: mat4, coordsId: number, eleDelta: number ): UniformValues => ({ - 'u_matrix': matrix, 'u_terrain_coords_id': coordsId / 255, 'u_texture': 0, 'u_ele_delta': eleDelta diff --git a/src/render/render_to_texture.test.ts b/src/render/render_to_texture.test.ts index 40da47e7db..19dfce3310 100644 --- a/src/render/render_to_texture.test.ts +++ b/src/render/render_to_texture.test.ts @@ -64,7 +64,7 @@ describe('render to texture', () => { const painter = { layersDrawn: 0, context: new Context(gl), - transform: {zoom: 10, calculatePosMatrix: () => {}, calculateFogMatrix: () => {}}, + transform: {zoom: 10, calculatePosMatrix: () => {}, getProjectionData(_a) {}, calculateFogMatrix: () => {}}, colorModeForRenderPass: () => ColorMode.alphaBlended, useProgram: () => { return {draw: () => { layersDrawn++; }}; }, _renderTileClippingMasks: () => {}, @@ -106,7 +106,24 @@ describe('render to texture', () => { test('check state', () => { expect(rtt._renderableTiles.map(t => t.tileID.key)).toStrictEqual(['923']); - expect(rtt._coordsDescendingInv).toEqual({'maine': {'923': [{'canonical': {'key': '922', 'x': 1, 'y': 2, 'z': 2}, 'key': '923', 'overscaledZ': 3, 'wrap': 0}]}}); + expect(rtt._coordsDescendingInv).toEqual({ + 'maine': { + '923': [ + { + 'canonical': { + 'key': '922', + 'x': 1, + 'y': 2, + 'z': 2 + }, + 'key': '923', + 'overscaledZ': 3, + 'wrap': 0, + 'terrainRttPosMatrix': null, + } + ] + } + }); expect(rtt._coordsDescendingInvStr).toStrictEqual({maine: {'923': '923'}}); }); diff --git a/src/render/render_to_texture.ts b/src/render/render_to_texture.ts index acd3c1e277..16ec56d04b 100644 --- a/src/render/render_to_texture.ts +++ b/src/render/render_to_texture.ts @@ -179,7 +179,7 @@ export class RenderToTexture { const layer = painter.style._layers[layers[l]]; const coords = layer.source ? this._coordsDescendingInv[layer.source][tile.tileID.key] : [tile.tileID]; painter.context.viewport.set([0, 0, obj.fbo.width, obj.fbo.height]); - painter._renderTileClippingMasks(layer, coords); + painter._renderTileClippingMasks(layer, coords, true); painter.renderLayer(painter, painter.style.sourceCaches[layer.source], layer, coords); if (layer.source) tile.rttCoords[layer.source] = this._coordsDescendingInvStr[layer.source][tile.tileID.key]; } diff --git a/src/render/subdivision.test.ts b/src/render/subdivision.test.ts new file mode 100644 index 0000000000..fd1162bfbb --- /dev/null +++ b/src/render/subdivision.test.ts @@ -0,0 +1,1102 @@ +import Point from '@mapbox/point-geometry'; +import {EXTENT} from '../data/extent'; +import {scanlineTriangulateVertexRing, subdividePolygon, subdivideVertexLine} from './subdivision'; +import {CanonicalTileID} from '../source/tile_id'; + +/** + * With this granularity, all geometry should be subdivided along axes divisible by 4. + */ +const granularityForInterval4 = EXTENT / 4; +const granularityForInterval128 = EXTENT / 128; + +const canonicalDefault = new CanonicalTileID(20, 1, 1); + +describe('Line geometry subdivision', () => { + test('Line inside cell remains unchanged', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(4, 4), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(4, 4), + ])); + + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(4, 0), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(4, 0), + ])); + + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 2), + new Point(4, 2), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(0, 2), + new Point(4, 2), + ])); + }); + + test('Simple line', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(1, 1), + new Point(6, 1), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(1, 1), + new Point(4, 1), + new Point(6, 1), + ])); + }); + + test('Simple ring', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(8, 0), + new Point(0, 8), + ], granularityForInterval4, true))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(4, 0), + new Point(8, 0), + new Point(4, 4), + new Point(0, 8), + new Point(0, 4), + new Point(0, 0), + ])); + }); + + test('Simple ring inside cell', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(8, 0), + new Point(0, 8), + ], granularityForInterval128, true))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(8, 0), + new Point(0, 8), + new Point(0, 0), + ])); + }); + + test('Simple ring is unchanged when granularity=0', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(8, 0), + new Point(0, 8), + ], 0, true))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(8, 0), + new Point(0, 8), + new Point(0, 0), + ])); + }); + + test('Line lies on subdivision axis', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(1, 0), + new Point(6, 0), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(1, 0), + new Point(4, 0), + new Point(6, 0), + ])); + }); + + test('Line circles a subdivision cell', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(4, 0), + new Point(4, 4), + new Point(0, 4), + new Point(0, 0), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(4, 0), + new Point(4, 4), + new Point(0, 4), + new Point(0, 0), + ])); + }); + + test('Line goes through cell vertices', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(4, 4), + new Point(8, 4), + new Point(8, 8), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(4, 4), + new Point(8, 4), + new Point(8, 8), + ])); + }); + + test('Line crosses several cells', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(0, 0), + new Point(12, 5), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(0, 0), + new Point(4, 2), + new Point(8, 3), + new Point(10, 4), + new Point(12, 5), + ])); + }); + + test('Line crosses several cells in negative coordinates', () => { + // Same geometry as the previous test, just shifted by -1000 in both axes + expect(toSimplePoints(subdivideVertexLine([ + new Point(-1000, -1000), + new Point(-1012, -1005), + ], granularityForInterval4))).toEqual(toSimplePoints([ + new Point(-1000, -1000), + new Point(-1004, -1002), + new Point(-1008, -1003), + new Point(-1010, -1004), + new Point(-1012, -1005), + ])); + }); + + test('Line is unmodified at granularity 1', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(-EXTENT * 4, 0), + new Point(EXTENT * 4, 0), + ], 1))).toEqual(toSimplePoints([ + new Point(-EXTENT * 4, 0), + new Point(EXTENT * 4, 0), + ])); + }); + + test('Line is unmodified at granularity 0', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(-EXTENT * 4, 0), + new Point(EXTENT * 4, 0), + ], 0))).toEqual(toSimplePoints([ + new Point(-EXTENT * 4, 0), + new Point(EXTENT * 4, 0), + ])); + }); + + test('Line is unmodified at granularity -2', () => { + expect(toSimplePoints(subdivideVertexLine([ + new Point(-EXTENT * 4, 0), + new Point(EXTENT * 4, 0), + ], -2))).toEqual(toSimplePoints([ + new Point(-EXTENT * 4, 0), + new Point(EXTENT * 4, 0), + ])); + }); +}); + +describe('Fill subdivision', () => { + test('Polygon is unchanged when granularity=1', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(20000, 0), + new Point(20000, 20000), + new Point(0, 20000), + ] + ], + canonicalDefault, + 1 + ); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + expect(result.verticesFlattened).toEqual([ + 0, 0, + 20000, 0, + 20000, 20000, + 0, 20000 + ]); + expect(result.indicesTriangles).toEqual([2, 0, 3, 0, 2, 1]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, + 1, 2, + 2, 3, + 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Polygon is unchanged when granularity=1, but winding order is corrected.', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(0, 20000), + new Point(20000, 20000), + new Point(20000, 0), + ] + ], + canonicalDefault, + 1 + ); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + expect(result.verticesFlattened).toEqual([ + 0, 0, + 0, 20000, + 20000, 20000, + 20000, 0 + ]); + expect(result.indicesTriangles).toEqual([1, 3, 0, 3, 1, 2]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, + 1, 2, + 2, 3, + 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Polygon inside cell is unchanged', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(2, 0), + new Point(2, 2), + new Point(0, 2), + ] + ], + canonicalDefault, + granularityForInterval4 + ); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + expect(result.verticesFlattened).toEqual([ + 0, 0, + 2, 0, + 2, 2, + 0, 2 + ]); + expect(result.indicesTriangles).toEqual([0, 3, 2, 1, 0, 2]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, + 1, 2, + 2, 3, + 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Subdivide a polygon', () => { + const result = subdividePolygon([ + [ + new Point(0, 0), + new Point(8, 0), + new Point(0, 8), + ], + [ + new Point(1, 1), + new Point(5, 1), + new Point(1, 5), + ] + ], canonicalDefault, granularityForInterval4); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + expect(result.verticesFlattened).toEqual([ + // // indices: + 0, 0, // 0 + 8, 0, // 1 + 0, 8, // 2 + 1, 1, // 3 + 5, 1, // 4 + 1, 5, // 5 + 1, 4, // 6 + 4, 1, // 7 + 0, 4, // 8 + 4, 0, // 9 + 4, 4, // 10 + 2, 4, // 11 + 4, 3, // 12 + 4, 2 // 13 + ]); + // X: 0 1 2 3 4 5 6 7 8 + // Y: | | | | | | | | | + // 0: 0 9 1 + // + // 1: 3 7 4 + // + // 2: 13 + // + // 3: 12 + // + // 4: 8 6 11 10 + // + // 5: 5 + // + // 6: + // + // 7: + // + // 8: 2 + expect(result.indicesTriangles).toEqual([ + 3, 0, 6, + 7, 0, 3, + 0, 8, 6, + 6, 8, 2, + 6, 2, 5, + 9, 0, 7, + 9, 7, 4, + 9, 4, 1, + 12, 11, 10, + 12, 10, 1, + 5, 2, 11, + 11, 2, 10, + 13, 11, 12, + 13, 12, 4, + 4, 12, 1 + ]); + // X: 0 1 2 3 4 5 6 7 8 + // Y: | | | | | | | | | + // 0: 0⎼⎼⎽⎽__---------9\--------------1 + // | ⟍ ⎺⎺⎻⎻⎼⎼⎽⎽ | ⟍ _⎼⎼⎻⎻⎺╱ + // 1: || 3-----------7---4⎻⎻⎺ ╱╱ + // || | ╱ ╱ ╱ + // 2: |⎹ | 13╱╱ ╱ ╱ + // | ⎹ | ╱ |╱ ╱ ╱ + // 3: | || ╱ _12 ╱ + // | ⎹| ╱_⎻⎺⎺ | ╱ + // 4: 8---6 11------10╱ + // | ⎹| ╱ ⎸ ╱ + // 5: | ⎹ 5 ⎸ ╱ + // | ⎸| ⎸ ╱ + // 6: |⎹⎹ ⎸ ╱ + // |⎹⎸ ⎸ ╱ + // 7: || ⎸ ╱ + // |⎸⎸╱ + // 8: 2╱ + expect(result.indicesLineList).toEqual([ + [ + 0, 9, + 9, 1, + 1, 10, + 10, 2, + 2, 8, + 8, 0 + ], + [ + 3, 7, + 7, 4, + 4, 13, + 13, 11, + 11, 5, + 5, 6, + 6, 3 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + describe('Polygon outline line list is correct', () => { + test('Subcell polygon', () => { + const result = subdividePolygon([ + [ + new Point(17, 127), + new Point(19, 111), + new Point(126, 13), + ] + ], canonicalDefault, granularityForInterval128); + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + testPolygonOutlineMatches(result.indicesTriangles, result.indicesLineList); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Small polygon', () => { + const result = subdividePolygon([ + [ + new Point(17, 15), + new Point(261, 13), + new Point(19, 273), + ] + ], canonicalDefault, granularityForInterval128); + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + testPolygonOutlineMatches(result.indicesTriangles, result.indicesLineList); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Medium polygon', () => { + const result = subdividePolygon([ + [ + new Point(17, 127), + new Point(1029, 13), + new Point(127, 1045), + ] + ], canonicalDefault, granularityForInterval128); + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + testPolygonOutlineMatches(result.indicesTriangles, result.indicesLineList); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Large polygon', () => { + const result = subdividePolygon([ + [ + new Point(17, 127), + new Point(8001, 13), + new Point(127, 8003), + ] + ], canonicalDefault, granularityForInterval128); + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + testPolygonOutlineMatches(result.indicesTriangles, result.indicesLineList); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Large polygon with hole', () => { + const result = subdividePolygon([ + [ + new Point(17, 127), + new Point(8001, 13), + new Point(127, 8003), + ], + [ + new Point(1001, 1002), + new Point(1502, 1008), + new Point(1004, 1523), + ] + ], canonicalDefault, granularityForInterval128); + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + testPolygonOutlineMatches(result.indicesTriangles, result.indicesLineList); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Large polygon with hole, granularity=0', () => { + const result = subdividePolygon([ + [ + new Point(17, 127), + new Point(8001, 13), + new Point(127, 8003), + ], + [ + new Point(1001, 1002), + new Point(1502, 1008), + new Point(1004, 1523), + ] + ], canonicalDefault, 0); + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + testPolygonOutlineMatches(result.indicesTriangles, result.indicesLineList); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Large polygon with hole, finer granularity', () => { + const result = subdividePolygon([ + [ + new Point(17, 1), + new Point(347, 13), + new Point(19, 453), + ], + [ + new Point(23, 7), + new Point(319, 17), + new Point(29, 399), + ] + ], canonicalDefault, EXTENT / 8); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + + // This polygon subdivision results in at least one edge that is shared among more than 2 triangles. + // This is not ideal, but it is also an edge case of a weird triangle getting subdivided by a very fine grid. + // Furthermore, one edge shared by multiple triangles is not a problem for map rendering, + // but it should *not* occur when subdividing any simple geometry. + + //testMeshIntegrity(result.indicesTriangles); + + // Polygon outline match test also fails for this specific edge case. + + //testPolygonOutlineMatches(result.indicesTriangles, result.indicesLineList); + }); + + test('Polygon with hole inside cell', () => { + // 0 + // / \ + // / 3 \ + // / / \ \ + // / / \ \ + // / 5⎺⎺⎺⎺4 \ + // 2⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺1 + const result = subdividePolygon( + [ + [ + new Point(0, 0), + new Point(3, 4), + new Point(-3, 4), + ], + [ + new Point(0, 1), + new Point(1, 3), + new Point(-1, 3), + ] + ], + canonicalDefault, + 0 + ); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + expect(result.verticesFlattened).toEqual([ + 0, 0, // 0 + 3, 4, // 1 + -3, 4, // 2 + 0, 1, // 3 + 1, 3, // 4 + -1, 3 // 5 + ]); + expect(result.indicesTriangles).toEqual([ + 2, 4, 5, + 3, 2, 5, + 1, 4, 2, + 3, 0, 2, + 0, 4, 1, + 4, 0, 3 + ]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, + 1, 2, + 2, 0 + ], + [ + 3, 4, + 4, 5, + 5, 3 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Polygon with duplicate vertex with hole inside cell', () => { + // 0 + // / \ + // // \\ + // // \\ + // /4⎺⎺⎺⎺⎺3\ + // 2⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺1 + const result = subdividePolygon( + [ + [ + new Point(0, 0), + new Point(3, 4), + new Point(-3, 4), + ], + [ + new Point(0, 0), + new Point(1, 3), + new Point(-1, 3), + ] + ], + canonicalDefault, + 0 + ); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + expect(result.verticesFlattened).toEqual([ + 0, 0, // 0 + 3, 4, // 1 + -3, 4, // 2 + 1, 3, // 3 + -1, 3 // 4 + ]); + expect(result.indicesTriangles).toEqual([ + 2, 3, 4, + 0, 2, 4, + 3, 1, 0, + 1, 3, 2 + ]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, + 1, 2, + 2, 0 + ], + [ + 0, 3, + 3, 4, + 4, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Polygon with duplicate edge inside cell', () => { + // Test a slightly degenerate polygon, where the hole is achieved using a duplicate edge + // 0 + // /|\ + // / 3 \ + // / / \ \ + // / / \ \ + // / 4⎺⎺⎺⎺⎺5 \ + // 2⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺1 + const result = subdividePolygon( + [ + [ + new Point(0, 0), + new Point(3, 4), + new Point(-3, 4), + new Point(0, 0), + new Point(0, 1), + new Point(-1, 3), + new Point(1, 3), + new Point(0, 1), + new Point(0, 0), + ] + ], + canonicalDefault, + 0 + ); + + expect(hasDuplicateVertices(result.verticesFlattened)).toBe(false); + testMeshIntegrity(result.indicesTriangles); + expect(result.verticesFlattened).toEqual([ + 0, 0, // 0 + 3, 4, // 1 + -3, 4, // 2 + 0, 1, // 3 + -1, 3, // 4 + 1, 3 // 5 + ]); + expect(result.indicesTriangles).toEqual([ + 3, 1, 0, + 2, 3, 0, + 5, 1, 3, + 2, 4, 3, + 4, 1, 5, + 1, 4, 2 + ]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, + 1, 2, + 2, 0, + 0, 3, + 3, 4, + 4, 5, + 5, 3, + 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + }); + + test('Generates pole geometry for both poles', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(EXTENT, 0), + new Point(EXTENT, EXTENT), + new Point(0, EXTENT), + ] + ], + new CanonicalTileID(0, 0, 0), + 2 + ); + expect(result.verticesFlattened).toEqual([ + 0, 0, // 0 + 8192, 0, // 1 + 8192, 8192, // 2 + 0, 8192, // 3 + 0, 4096, // 4 + 4096, 4096, // 5 + 4096, 8192, // 6 + 4096, 0, // 7 + 8192, 4096, // 8 + 0, 32767, // 9 - South pole - 3 vertices + 4096, 32767, // 10 + 8192, 32767, // 11 + 4096, -32768, // 12 - North pole - 3 vertices + 0, -32768, // 13 + 8192, -32768 // 14 + ]); + // 0 4096 8192 + // | | | + // -32K: 13 12 14 + // + // 0: 0 7 1 + // + // 4096: 4 5 8 + // + // 8192: 3 6 2 + // + // 32K: 9 10 11 + expect(result.indicesTriangles).toEqual([ + 0, 4, 5, + 4, 3, 5, + 5, 3, 6, + 5, 6, 2, + 7, 0, 5, + 7, 5, 1, + 1, 5, 8, + 8, 5, 2, + 6, 3, 9, + 10, 6, 9, + 2, 6, 10, + 11, 2, 10, + 0, 7, 12, + 13, 0, 12, + 7, 1, 14, + 12, 7, 14 + ]); + // The outline intersects the added pole geometry - but that shouldn't be an issue. + expect(result.indicesLineList).toEqual([ + [ + 0, 7, + 7, 1, + 1, 8, + 8, 2, + 2, 6, + 6, 3, + 3, 4, + 4, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Generates pole geometry for north pole only (geometry not bordering other pole)', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(EXTENT, 0), + new Point(EXTENT, EXTENT), // Note that one of the vertices touches the south edge... + new Point(0, EXTENT - 1), // ...the other does not. + ] + ], + new CanonicalTileID(0, 0, 0), + 1 + ); + expect(result.verticesFlattened).toEqual([ + 0, 0, + 8192, 0, + 8192, 8192, + 0, 8191, + 8192, -32768, + 0, -32768 + ]); + expect(result.indicesTriangles).toEqual([ + 2, 0, 3, 0, 2, + 1, 0, 1, 4, 5, + 0, 4 + ]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, 1, 2, + 2, 3, 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Generates pole geometry for south pole only (geometry not bordering other pole)', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(EXTENT, 1), + new Point(EXTENT, EXTENT), + new Point(0, EXTENT), + ] + ], + new CanonicalTileID(0, 0, 0), + 1 + ); + expect(result.verticesFlattened).toEqual([ + 0, 0, + 8192, 1, + 8192, 8192, + 0, 8192, + 0, 32767, + 8192, 32767 + ]); + expect(result.indicesTriangles).toEqual([ + 2, 0, 3, 0, 2, + 1, 2, 3, 4, 5, + 2, 4 + ]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, 1, 2, + 2, 3, 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Generates pole geometry for north pole only (tile not bordering other pole)', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(EXTENT, 0), + new Point(EXTENT, EXTENT), + new Point(0, EXTENT), + ] + ], + new CanonicalTileID(1, 0, 0), + 1 + ); + expect(result.verticesFlattened).toEqual([ + 0, 0, + 8192, 0, + 8192, 8192, + 0, 8192, + 8192, -32768, + 0, -32768 + ]); + expect(result.indicesTriangles).toEqual([ + 2, 0, 3, 0, 2, + 1, 0, 1, 4, 5, + 0, 4 + ]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, 1, 2, + 2, 3, 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Generates pole geometry for south pole only (tile not bordering other pole)', () => { + const result = subdividePolygon( + [ + [ + // x, y + new Point(0, 0), + new Point(EXTENT, 0), + new Point(EXTENT, EXTENT), + new Point(0, EXTENT), + ] + ], + new CanonicalTileID(1, 0, 1), + 1 + ); + expect(result.verticesFlattened).toEqual([ + 0, 0, + 8192, 0, + 8192, 8192, + 0, 8192, + 0, 32767, + 8192, 32767 + ]); + expect(result.indicesTriangles).toEqual([ + 2, 0, 3, 0, 2, + 1, 2, 3, 4, 5, + 2, 4 + ]); + expect(result.indicesLineList).toEqual([ + [ + 0, 1, 1, 2, + 2, 3, 3, 0 + ] + ]); + checkWindingOrder(result.verticesFlattened, result.indicesTriangles); + }); + + test('Scanline subdivision ring generation case 1', () => { + // Check ring generation on data where it was actually failing + const vertices = [ + 243, 152, // 0 + 240, 157, // 1 + 237, 160, // 2 + 232, 160, // 3 + 226, 160, // 4 + 232, 153, // 5 + 232, 152, // 6 + 240, 152 // 7 + ]; + // This vertex ring is slightly degenerate (4-5-6 is concave) + // 226 232 237 240 243 + // | | | | | + // 152: 6 7 0 + // 153: 5 + // + // + // + // 157: 1 + // + // + // 160: 4 3 2 + const ring = [0, 1, 2, 3, 4, 5, 6, 7]; + const finalIndices = []; + scanlineTriangulateVertexRing(vertices, ring, finalIndices); + checkWindingOrder(vertices, finalIndices); + }); + + test('Scanline subdivision ring generation case 2', () => { + // It should pass on this data + const vertices = [210, 160, 216, 153, 217, 152, 224, 152, 232, 152, 232, 152, 232, 153, 226, 160, 224, 160, 216, 160]; + const ring = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + const finalIndices = []; + scanlineTriangulateVertexRing(vertices, ring, finalIndices); + checkWindingOrder(vertices, finalIndices); + }); +}); + +/** + * Converts an array of points into an array of simple \{x, y\} objects. + * Jest prints much nicer comparisons on arrays of these simple objects than on + * arrays of points. + */ +function toSimplePoints(a: Array): Array<{x: number; y: number}> { + const result = []; + for (let i = 0; i < a.length; i++) { + result.push({ + x: a[i].x, + y: a[i].y, + }); + } + return result; +} + +function getEdgeOccurrencesMap(triangleIndices: Array): Map { + const edgeOccurrences = new Map(); + for (let triangleIndex = 0; triangleIndex < triangleIndices.length; triangleIndex += 3) { + const i0 = triangleIndices[triangleIndex]; + const i1 = triangleIndices[triangleIndex + 1]; + const i2 = triangleIndices[triangleIndex + 2]; + for (const edge of [[i0, i1], [i1, i2], [i2, i0]]) { + const e0 = Math.min(edge[0], edge[1]); + const e1 = Math.max(edge[0], edge[1]); + const key = `${e0}_${e1}`; + if (edgeOccurrences.has(key)) { + edgeOccurrences.set(key, edgeOccurrences.get(key) + 1); + } else { + edgeOccurrences.set(key, 1); + } + } + } + return edgeOccurrences; +} + +/** + * Checks that the supplied mesh has no edge that is shared by more than 2 triangles. + */ +function testMeshIntegrity(triangleIndices: Array) { + const edgeOccurrences = getEdgeOccurrencesMap(triangleIndices); + for (const pair of edgeOccurrences) { + if (pair[1] > 2) { + throw new Error(`Polygon contains an edge with indices ${pair[0].replace('_', ', ')} that is shared by more than 2 triangles.`); + } + } +} + +/** + * Checks that the lines in `lineIndicesLists` actually match the exposed edges of the triangle mesh in `triangleIndices`. + */ +function testPolygonOutlineMatches(triangleIndices: Array, lineIndicesLists: Array>): void { + const edgeOccurrences = getEdgeOccurrencesMap(triangleIndices); + const uncoveredEdges = new Set(); + + for (const pair of edgeOccurrences) { + if (pair[1] === 1) { + uncoveredEdges.add(pair[0]); + } + } + + const outlineEdges = new Set(); + + for (const lines of lineIndicesLists) { + for (let i = 0; i < lines.length; i += 2) { + const i0 = lines[i]; + const i1 = lines[i + 1]; + const e0 = Math.min(i0, i1); + const e1 = Math.max(i0, i1); + const key = `${e0}_${e1}`; + if (outlineEdges.has(key)) { + throw new Error(`Outline line lists contain edge with indices ${e0}, ${e1} multiple times.`); + } + outlineEdges.add(key); + } + } + + if (uncoveredEdges.size !== outlineEdges.size) { + throw new Error(`Polygon exposed triangle edge count ${uncoveredEdges.size} and outline line count ${outlineEdges.size} does not match.`); + } + + expect(isSubsetOf(outlineEdges, uncoveredEdges)).toBe(true); + expect(isSubsetOf(uncoveredEdges, outlineEdges)).toBe(true); +} + +function isSubsetOf(a: Set, b: Set): boolean { + for (const key of b) { + if (!a.has(key)) { + return false; + } + } + return true; +} + +function hasDuplicateVertices(flattened: Array): boolean { + const set = new Set(); + for (let i = 0; i < flattened.length; i += 2) { + const vx = flattened[i]; + const vy = flattened[i + 1]; + const key = `${vx}_${vy}`; + if (set.has(key)) { + return true; + } + set.add(key); + } + return false; +} + +/** + * Passes if all triangles have the correct winding order, otherwise throws. + */ +function checkWindingOrder(flattened: Array, indices: Array): void { + for (let i = 0; i < indices.length; i += 3) { + const i0 = indices[i]; + const i1 = indices[i + 1]; + const i2 = indices[i + 2]; + + const v0x = flattened[i0 * 2]; + const v0y = flattened[i0 * 2 + 1]; + const v1x = flattened[i1 * 2]; + const v1y = flattened[i1 * 2 + 1]; + const v2x = flattened[i2 * 2]; + const v2y = flattened[i2 * 2 + 1]; + + const e0x = v1x - v0x; + const e0y = v1y - v0y; + const e1x = v2x - v0x; + const e1y = v2y - v0y; + + const crossProduct = e0x * e1y - e0y * e1x; + + if (crossProduct > 0) { + // Incorrect + throw new Error(`Found triangle with wrong winding order! Indices: [${i0} ${i1} ${i2}] Vertices: [(${v0x} ${v0y}) (${v1x} ${v1y}) (${v2x} ${v2y})]`); + } + } +} diff --git a/src/render/subdivision.ts b/src/render/subdivision.ts new file mode 100644 index 0000000000..72c09cc83a --- /dev/null +++ b/src/render/subdivision.ts @@ -0,0 +1,999 @@ +import Point from '@mapbox/point-geometry'; +import {EXTENT} from '../data/extent'; +import {CanonicalTileID} from '../source/tile_id'; +import earcut from 'earcut'; +import {SubdivisionGranularityExpression, SubdivisionGranularitySetting} from './subdivision_granularity_settings'; +import {register} from '../util/web_worker_transfer'; + +register('SubdivisionGranularityExpression', SubdivisionGranularityExpression); +register('SubdivisionGranularitySetting', SubdivisionGranularitySetting); + +type SubdivisionResult = { + verticesFlattened: Array; + indicesTriangles: Array; + + /** + * An array of arrays of indices of subdivided lines for polygon outlines. + * Each array of lines corresponds to one ring of the original polygon. + */ + indicesLineList: Array>; +}; + +// Special pole vertices have coordinates -32768,-32768 for the north pole and 32767,32767 for the south pole. +// First, find any *non-pole* vertices at those coordinates and move them slightly elsewhere. +export const NORTH_POLE_Y = -32768; +export const SOUTH_POLE_Y = 32767; + +class Subdivider { + /** + * Flattened vertex positions (xyxyxy). + */ + private _vertexBuffer: Array = []; + + /** + * Map of "vertex x and y coordinate" to "index of such vertex". + */ + private _vertexDictionary: Map = new Map(); + private _used: boolean = false; + + private readonly _canonical: CanonicalTileID; + + private readonly _granularity; + private readonly _granularityCellSize; + + constructor(granularity: number, canonical: CanonicalTileID) { + this._granularity = granularity; + this._granularityCellSize = EXTENT / granularity; + this._canonical = canonical; + } + + private _getKey(x: number, y: number) { + // Assumes signed 16 bit positions. + x = x + 32768; + y = y + 32768; + return (x << 16) | (y << 0); + } + + /** + * Returns an index into the internal vertex buffer for a vertex at the given coordinates. + * If the internal vertex buffer contains no such vertex, then it is added. + */ + private _vertexToIndex(x: number, y: number): number { + if (x < -32768 || y < -32768 || x > 32767 || y > 32767) { + throw new Error('Vertex coordinates are out of signed 16 bit integer range.'); + } + const xInt = Math.round(x) | 0; + const yInt = Math.round(y) | 0; + const key = this._getKey(xInt, yInt); + if (this._vertexDictionary.has(key)) { + return this._vertexDictionary.get(key); + } + const index = this._vertexBuffer.length / 2; + this._vertexDictionary.set(key, index); + this._vertexBuffer.push(xInt, yInt); + return index; + } + + /** + * Subdivides a polygon by iterating over rows of granularity subdivision cells and splitting each row along vertical subdivision axes. + * @param inputIndices - Indices into the internal vertex buffer of the triangulated polygon (after running `earcut`). + * @returns Indices into the internal vertex buffer for triangles that are a subdivision of the input geometry. + */ + private _subdivideTrianglesScanline(inputIndices: Array): Array { + // A granularity cell is the square space between axes that subdivide geometry. + // For granularity 8, cells would be 1024 by 1024 units. + // For each triangle, we iterate over all cell rows it intersects, and generate subdivided geometry + // only within one cell row at a time. This way, we implicitly subdivide along the X-parallel axes (cell row boundaries). + // For each cell row, we generate an ordered point ring that describes the subdivided geometry inside this row (an intersection of the triangle and a given cell row). + // Such ordered ring can be trivially triangulated. + // Each ring may consist of sections of triangle edges that lie inside the cell row, and cell boundaries that lie inside the triangle. Both must be further subdivided along Y-parallel axes. + // Most complexity of this function comes from generating correct vertex rings, and from placing the vertices into the ring in the correct order. + + if (this._granularity < 2) { + // The actual subdivision code always produces triangles with the correct winding order. + // Also apply winding order correction when skipping subdivision altogether to maintain consistency. + return fixWindingOrder(this._vertexBuffer, inputIndices); + } + + const finalIndices = []; + + // Iterate over all input triangles + const numIndices = inputIndices.length; + for (let primitiveIndex = 0; primitiveIndex < numIndices; primitiveIndex += 3) { + const triangleIndices: [number, number, number] = [ + inputIndices[primitiveIndex + 0], // v0 + inputIndices[primitiveIndex + 1], // v1 + inputIndices[primitiveIndex + 2], // v2 + ]; + + const triangleVertices: [number, number, number, number, number, number] = [ + this._vertexBuffer[inputIndices[primitiveIndex + 0] * 2 + 0], // v0.x + this._vertexBuffer[inputIndices[primitiveIndex + 0] * 2 + 1], // v0.y + this._vertexBuffer[inputIndices[primitiveIndex + 1] * 2 + 0], // v1.x + this._vertexBuffer[inputIndices[primitiveIndex + 1] * 2 + 1], // v1.y + this._vertexBuffer[inputIndices[primitiveIndex + 2] * 2 + 0], // v2.x + this._vertexBuffer[inputIndices[primitiveIndex + 2] * 2 + 1], // v2.y + ]; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + // Compute AABB + for (let i = 0; i < 3; i++) { + const vx = triangleVertices[i * 2]; + const vy = triangleVertices[i * 2 + 1]; + minX = Math.min(minX, vx); + maxX = Math.max(maxX, vx); + minY = Math.min(minY, vy); + maxY = Math.max(maxY, vy); + } + + if (minX === maxX || minY === maxY) { + continue; // Skip degenerate linear axis-aligned triangles + } + + const cellXmin = Math.floor(minX / this._granularityCellSize); + const cellXmax = Math.ceil(maxX / this._granularityCellSize); + const cellYmin = Math.floor(minY / this._granularityCellSize); + const cellYmax = Math.ceil(maxY / this._granularityCellSize); + + // Skip subdividing triangles that do not span multiple cells - just add them "as is". + if (cellXmin === cellXmax && cellYmin === cellYmax) { + finalIndices.push(...triangleIndices); + continue; + } + + // Iterate over cell rows that intersect this triangle + for (let cellRow = cellYmin; cellRow < cellYmax; cellRow++) { + const ring = this._scanlineGenerateVertexRingForCellRow(cellRow, triangleVertices, triangleIndices); + scanlineTriangulateVertexRing(this._vertexBuffer, ring, finalIndices); + } + } + + return finalIndices; + } + + /** + * Takes a triangle and a cell row index, returns a subdivided vertex ring of the intersection of the triangle and the cell row. + * @param cellRow - Index of the cell row. A cell row of index `i` covert range from `i * granularityCellSize` to `(i + 1) * granularityCellSize`. + * @param triangleVertices - An array of 6 elements, contains flattened positions of the triangle's vertices: `[v0x, v0y, v1x, v1y, v2x, v2y]`. + * @param triangleIndices - An array of 3 elements, contains the original indices of the triangle's vertices: `[index0, index1, index2]`. + * @returns The resulting ring of vertex indices and the index (to the returned ring array) of the leftmost vertex in the ring. + */ + private _scanlineGenerateVertexRingForCellRow( + cellRow: number, + triangleVertices: [number, number, number, number, number, number], + triangleIndices: [number, number, number] + ) { + const cellRowYTop = cellRow * this._granularityCellSize; + const cellRowYBottom = cellRowYTop + this._granularityCellSize; + const ring = []; + + // Generate the vertex ring + for (let edgeIndex = 0; edgeIndex < 3; edgeIndex++) { + // Current edge that will be subdivided: a --> b + // The remaining vertex of the triangle: c + const aX = triangleVertices[edgeIndex * 2]; + const aY = triangleVertices[edgeIndex * 2 + 1]; + const bX = triangleVertices[((edgeIndex + 1) * 2) % 6]; + const bY = triangleVertices[((edgeIndex + 1) * 2 + 1) % 6]; + const cX = triangleVertices[((edgeIndex + 2) * 2) % 6]; + const cY = triangleVertices[((edgeIndex + 2) * 2 + 1) % 6]; + // Edge direction + const dirX = bX - aX; + const dirY = bY - aY; + + // Edges parallel with either axis will need special handling later. + const isParallelY = dirX === 0; + const isParallelX = dirY === 0; + + // Distance along edge where it enters/exits current cell row, + // where distance 0 is the edge start point, 1 the endpoint, 0.5 the mid point, etc. + const tTop = (cellRowYTop - aY) / dirY; + const tBottom = (cellRowYBottom - aY) / dirY; + const tEnter = Math.min(tTop, tBottom); + const tExit = Math.max(tTop, tBottom); + + // Determine if edge lies entirely outside this cell row. + // Check entry and exit points, or if edge is parallel with X, check its Y coordinate. + if ((!isParallelX && (tEnter >= 1 || tExit <= 0)) || + (isParallelX && (aY < cellRowYTop || aY > cellRowYBottom))) { + // Skip this edge + // But make sure to add its endpoint vertex if needed. + if (bY >= cellRowYTop && bY <= cellRowYBottom) { + // The edge endpoint is withing this row, add it to the ring + ring.push(triangleIndices[(edgeIndex + 1) % 3]); + } + continue; + } + + // Do not add original triangle vertices now, those are handled separately later + + // Special case: edge vertex for entry into cell row + // If edge is parallel with X axis, there is no entry vertex + if (!isParallelX && tEnter > 0) { + const x = aX + dirX * tEnter; + const y = aY + dirY * tEnter; + ring.push(this._vertexToIndex(x, y)); + } + + // The X coordinates of the points where the edge enters/exits the current cell row, + // or the edge start/endpoint, if the entry/exit happens beyond the edge bounds. + const enterX = aX + dirX * Math.max(tEnter, 0); + const exitX = aX + dirX * Math.min(tExit, 1); + + // Generate edge interior vertices + // No need to subdivide (along X) edges that are parallel with Y + if (!isParallelY) { + this._generateIntraEdgeVertices(ring, aX, aY, bX, bY, enterX, exitX); + } + + // Special case: edge vertex for exit from cell row + if (!isParallelX && tExit < 1) { + const x = aX + dirX * tExit; + const y = aY + dirY * tExit; + ring.push(this._vertexToIndex(x, y)); + } + + // When to split inter-edge boundary segments? + // When the boundary doesn't intersect a vertex, its easy. But what if it does? + + // a + // /| + // / | + // --c--|--boundary + // \ | + // \| + // b + // + // Inter-edge region should be generated when processing the a-b edge. + // This happens fine for the top row, for the bottom row, + // + + // x + // /| + // / | + // --x--x--boundary + // + // Edge that lies on boundary should be subdivided in its edge phase. + // The inter-edge phase will correctly skip it. + + // Add endpoint vertex + if (isParallelX || (bY >= cellRowYTop && bY <= cellRowYBottom)) { + ring.push(triangleIndices[(edgeIndex + 1) % 3]); + } + // Any edge that has endpoint outside this row or on its boundary gets + // inter-edge vertices. + // No row boundary to split for edges parallel with X + if (!isParallelX && (bY <= cellRowYTop || bY >= cellRowYBottom)) { + this._generateInterEdgeVertices(ring, aX, aY, bX, bY, cX, cY, + exitX, cellRowYTop, cellRowYBottom); + } + } + + return ring; + } + + /** + * Generates ring vertices along an edge A-\>B, but only in the part that intersects a given cell row. + * Does not handle adding edge endpoint vertices or edge cell row enter/exit vertices. + * @param ring - Ordered array of vertex indices for the constructed ring. New indices are placed here. + * @param enterX - The X coordinate of the point where edge A-\>B enters the current cell row. + * @param exitX - The X coordinate of the point where edge A-\>B exits the current cell row. + */ + private _generateIntraEdgeVertices( + ring: Array, + aX: number, + aY: number, + bX: number, + bY: number, + enterX: number, + exitX: number + ): void { + const dirX = bX - aX; + const dirY = bY - aY; + const isParallelX = dirY === 0; + + const leftX = isParallelX ? Math.min(aX, bX) : Math.min(enterX, exitX); + const rightX = isParallelX ? Math.max(aX, bX) : Math.max(enterX, exitX); + + const edgeSubdivisionLeftCellX = Math.floor(leftX / this._granularityCellSize) + 1; + const edgeSubdivisionRightCellX = Math.ceil(rightX / this._granularityCellSize) - 1; + + const isEdgeLeftToRight = isParallelX ? (aX < bX) : (enterX < exitX); + if (isEdgeLeftToRight) { + // Left to right + for (let cellX = edgeSubdivisionLeftCellX; cellX <= edgeSubdivisionRightCellX; cellX++) { + const x = cellX * this._granularityCellSize; + const y = aY + dirY * (x - aX) / dirX; + ring.push(this._vertexToIndex(x, y)); + } + } else { + // Right to left + for (let cellX = edgeSubdivisionRightCellX; cellX >= edgeSubdivisionLeftCellX; cellX--) { + const x = cellX * this._granularityCellSize; + const y = aY + dirY * (x - aX) / dirX; + ring.push(this._vertexToIndex(x, y)); + } + } + } + + /** + * Generates ring vertices along cell border. + * Call when processing an edge A-\>B that exits the current row (B lies outside the current row). + * Generates vertices along the cell edge between the exit point from cell row + * of edge A-\>B and entry of edge B-\>C, or entry of C-\>A if both A and C lie outside the cell row. + * Does not handle adding edge endpoint vertices or edge cell row enter/exit vertices. + * @param ring - Ordered array of vertex indices for the constructed ring. New indices are placed here. + * @param exitX - The X coordinate of the point where edge A-\>B exits the current cell row. + * @param cellRowYTop - The current cell row top Y coordinate. + * @param cellRowYBottom - The current cell row bottom Y coordinate. + */ + private _generateInterEdgeVertices( + ring: Array, + aX: number, + aY: number, + bX: number, + bY: number, + cX: number, + cY: number, + exitX: number, + cellRowYTop: number, + cellRowYBottom: number + ): void { + const dirY = bY - aY; + + const dir2X = cX - bX; + const dir2Y = cY - bY; + const t2Top = (cellRowYTop - bY) / dir2Y; + const t2Bottom = (cellRowYBottom - bY) / dir2Y; + // The distance along edge B->C where it enters/exits the current cell row, + // where distance 0 is B, 1 is C, 0.5 is the edge midpoint, etc. + const t2Enter = Math.min(t2Top, t2Bottom); + const t2Exit = Math.max(t2Top, t2Bottom); + const enter2X = bX + dir2X * t2Enter; + let boundarySubdivisionLeftCellX = Math.floor(Math.min(enter2X, exitX) / this._granularityCellSize) + 1; + let boundarySubdivisionRightCellX = Math.ceil(Math.max(enter2X, exitX) / this._granularityCellSize) - 1; + let isBoundaryLeftToRight = exitX < enter2X; + + const isParallelX2 = dir2Y === 0; + + if (isParallelX2 && (cY === cellRowYTop || cY === cellRowYBottom)) { + // Special case when edge b->c that lies on the cell boundary. + // Do not generate any inter-edge vertices in this case, + // this b->c edge gets subdivided when it is itself processed. + return; + } + + if (isParallelX2 || t2Enter >= 1 || t2Exit <= 0) { + // The next edge (b->c) lies entirely outside this cell row + // Find entry point for the edge after that instead (c->a) + + // There may be at most 1 edge that is parallel to X in a triangle. + // The main "a->b" edge must not be parallel at this point in the code. + // We know that "a->b" crosses the current cell row boundary, such that point "b" is beyond the boundary. + // If "b->c" is parallel to X, then "c->a" must not be parallel and must cross the cell row boundary back: + // a + // |\ + // -----|-\--cell row boundary---- + // | \ + // c---b + // If "b->c" is not parallel to X and doesn't cross the cell row boundary, + // then c->a must also not be parallel to X and must cross the cell boundary back, + // since points "a" and "c" lie on different sides of the boundary and on different Y coordinates. + // + // Thus there is no need for "parallel with X" checks inside this condition branch. + + // Compute the X coordinate where edge C->A enters the current cell row + const dir3X = aX - cX; + const dir3Y = aY - cY; + const t3Top = (cellRowYTop - cY) / dir3Y; + const t3Bottom = (cellRowYBottom - cY) / dir3Y; + const t3Enter = Math.min(t3Top, t3Bottom); + const enter3X = cX + dir3X * t3Enter; + + boundarySubdivisionLeftCellX = Math.floor(Math.min(enter3X, exitX) / this._granularityCellSize) + 1; + boundarySubdivisionRightCellX = Math.ceil(Math.max(enter3X, exitX) / this._granularityCellSize) - 1; + isBoundaryLeftToRight = exitX < enter3X; + } + + const boundaryY = dirY > 0 ? cellRowYBottom : cellRowYTop; + if (isBoundaryLeftToRight) { + // Left to right + for (let cellX = boundarySubdivisionLeftCellX; cellX <= boundarySubdivisionRightCellX; cellX++) { + const x = cellX * this._granularityCellSize; + ring.push(this._vertexToIndex(x, boundaryY)); + } + } else { + // Right to left + for (let cellX = boundarySubdivisionRightCellX; cellX >= boundarySubdivisionLeftCellX; cellX--) { + const x = cellX * this._granularityCellSize; + ring.push(this._vertexToIndex(x, boundaryY)); + } + } + } + + /** + * Generates an outline for a given polygon, returns a list of arrays of line indices. + */ + private _generateOutline(polygon: Array>): Array> { + const subdividedLines: Array> = []; + for (const ring of polygon) { + const line = subdivideVertexLine(ring, this._granularity, true); + const pathIndices = this._pointArrayToIndices(line); + // Points returned by subdivideVertexLine are "path" waypoints, + // for example with indices 0 1 2 3 0. + // We need list of individual line segments for rendering, + // for example 0, 1, 1, 2, 2, 3, 3, 0. + const lineIndices: Array = []; + for (let i = 1; i < pathIndices.length; i++) { + lineIndices.push(pathIndices[i - 1]); + lineIndices.push(pathIndices[i]); + } + subdividedLines.push(lineIndices); + } + return subdividedLines; + } + + /** + * Adds pole geometry if needed. + * @param subdividedTriangles - Array of generated triangle indices, new pole geometry is appended here. + */ + private _handlePoles(subdividedTriangles: Array) { + // Add pole vertices if the tile is at north/south mercator edge + let north = false; + let south = false; + if (this._canonical) { + if (this._canonical.y === 0) { + north = true; + } + if (this._canonical.y === (1 << this._canonical.z) - 1) { + south = true; + } + } + if (north || south) { + this._fillPoles(subdividedTriangles, north, south); + } + } + + /** + * Checks the internal vertex buffer for all vertices that might lie on the special pole coordinates and shifts them by one unit. + * Use for removing unintended pole vertices that might have been created during subdivision. After calling this function, actual pole vertices can be safely generated. + */ + private _ensureNoPoleVertices() { + const flattened = this._vertexBuffer; + + for (let i = 0; i < flattened.length; i += 2) { + const vy = flattened[i + 1]; + if (vy === NORTH_POLE_Y) { + // Move slightly down + flattened[i + 1] = NORTH_POLE_Y + 1; + } + if (vy === SOUTH_POLE_Y) { + // Move slightly down + flattened[i + 1] = SOUTH_POLE_Y - 1; + } + } + } + + /** + * Generates a quad from an edge to a pole with the correct winding order. + * Helper function used inside {@link _fillPoles}. + * @param indices - Index array into which the geometry is generated. + * @param i0 - Index of the first edge vertex. + * @param i1 - Index of the second edge vertex. + * @param v0x - X coordinate of the first edge vertex. + * @param v1x - X coordinate of the second edge vertex. + * @param poleY - The Y coordinate of the desired pole (NORTH_POLE_Y or SOUTH_POLE_Y). + */ + private _generatePoleQuad(indices, i0, i1, v0x, v1x, poleY): void { + const flip = (v0x > v1x) !== (poleY === NORTH_POLE_Y); + + if (flip) { + indices.push(i0); + indices.push(i1); + indices.push(this._vertexToIndex(v0x, poleY)); + + indices.push(i1); + indices.push(this._vertexToIndex(v1x, poleY)); + indices.push(this._vertexToIndex(v0x, poleY)); + } else { + indices.push(i1); + indices.push(i0); + indices.push(this._vertexToIndex(v0x, poleY)); + + indices.push(this._vertexToIndex(v1x, poleY)); + indices.push(i1); + indices.push(this._vertexToIndex(v0x, poleY)); + } + } + + /** + * Detects edges that border the north or south tile edge + * and adds triangles that extend those edges to the poles. + * Only run this function on tiles that border the poles. + * Assumes that supplied geometry is clipped to the inclusive range of 0..EXTENT. + * Mutates the supplies vertex and index arrays. + * @param indices - Triangle indices. This array is appended with new primitives. + * @param north - Whether to generate geometry for the north pole. + * @param south - Whether to generate geometry for the south pole. + */ + private _fillPoles(indices: Array, north: boolean, south: boolean): void { + const flattened = this._vertexBuffer; + + const northEdge = 0; + const southEdge = EXTENT; + + const numIndices = indices.length; + for (let primitiveIndex = 2; primitiveIndex < numIndices; primitiveIndex += 3) { + const i0 = indices[primitiveIndex - 2]; + const i1 = indices[primitiveIndex - 1]; + const i2 = indices[primitiveIndex]; + const v0x = flattened[i0 * 2]; + const v0y = flattened[i0 * 2 + 1]; + const v1x = flattened[i1 * 2]; + const v1y = flattened[i1 * 2 + 1]; + const v2x = flattened[i2 * 2]; + const v2y = flattened[i2 * 2 + 1]; + + if (north) { + if (v0y === northEdge && v1y === northEdge) { + this._generatePoleQuad(indices, i0, i1, v0x, v1x, NORTH_POLE_Y); + } + if (v1y === northEdge && v2y === northEdge) { + this._generatePoleQuad(indices, i1, i2, v1x, v2x, NORTH_POLE_Y); + } + if (v2y === northEdge && v0y === northEdge) { + this._generatePoleQuad(indices, i2, i0, v2x, v0x, NORTH_POLE_Y); + } + } + if (south) { + if (v0y === southEdge && v1y === southEdge) { + this._generatePoleQuad(indices, i0, i1, v0x, v1x, SOUTH_POLE_Y); + } + if (v1y === southEdge && v2y === southEdge) { + this._generatePoleQuad(indices, i1, i2, v1x, v2x, SOUTH_POLE_Y); + } + if (v2y === southEdge && v0y === southEdge) { + this._generatePoleQuad(indices, i2, i0, v2x, v0x, SOUTH_POLE_Y); + } + } + } + } + + /** + * Adds all vertices in the supplied flattened vertex buffer into the internal vertex buffer. + */ + private _initializeVertices(flattened: Array) { + for (let i = 0; i < flattened.length; i += 2) { + this._vertexToIndex(flattened[i], flattened[i + 1]); + } + } + + /** + * Subdivides an input mesh. Imagine a regular square grid with the target granularity overlaid over the mesh - this is the subdivision's result. + * Assumes a mesh of tile features - vertex coordinates are integers, visible range where subdivision happens is 0..8192. + * @param polygon - The input polygon, specified as a list of vertex rings. + * @param generateOutlineLines - When true, also generates line indices for outline of the supplied polygon. + * @returns Vertex and index buffers with subdivision applied. + */ + public subdividePolygonInternal(polygon: Array>, generateOutlineLines: boolean): SubdivisionResult { + if (this._used) { + throw new Error('Subdivision: multiple use not allowed.'); + } + this._used = true; + + // Initialize the vertex dictionary with input vertices since we will use all of them anyway + const {flattened, holeIndices} = flatten(polygon); + this._initializeVertices(flattened); + + // Subdivide triangles + let subdividedTriangles: Array; + try { + // At this point this._finalVertices is just flattened polygon points + const earcutResult = earcut(flattened, holeIndices); + const cut = this._convertIndices(flattened, earcutResult); + subdividedTriangles = this._subdivideTrianglesScanline(cut); + } catch (e) { + console.error(e); + } + + // Subdivide lines + let subdividedLines: Array> = []; + if (generateOutlineLines) { + subdividedLines = this._generateOutline(polygon); + } + + // Ensure no vertex has the special value used for pole vertices + this._ensureNoPoleVertices(); + + // Add pole geometry if needed + this._handlePoles(subdividedTriangles); + + return { + verticesFlattened: this._vertexBuffer, + indicesTriangles: subdividedTriangles, + indicesLineList: subdividedLines, + }; + } + + /** + * Sometimes the supplies vertex and index array has duplicate vertices - same coordinates that are referenced by multiple different indices. + * That is not allowed for purposes of subdivision, duplicates are removed in `this.initializeVertices`. + * This function converts the original index array that indexes into the original vertex array with duplicates + * into an index array that indexes into `this._finalVertices`. + * @param vertices - Flattened vertex array used by the old indices. This may contain duplicate vertices. + * @param oldIndices - Indices into the old vertex array. + * @returns Indices transformed so that they are valid indices into `this._finalVertices` (with duplicates removed). + */ + private _convertIndices(vertices: Array, oldIndices: Array): Array { + const newIndices = []; + for (let i = 0; i < oldIndices.length; i++) { + const x = vertices[oldIndices[i] * 2]; + const y = vertices[oldIndices[i] * 2 + 1]; + newIndices.push(this._vertexToIndex(x, y)); + } + return newIndices; + } + + /** + * Converts an array of points into an array of indices into the internal vertex buffer (`_finalVertices`). + */ + private _pointArrayToIndices(array: Array): Array { + const indices = []; + for (let i = 0; i < array.length; i++) { + const p = array[i]; + indices.push(this._vertexToIndex(p.x, p.y)); + } + return indices; + } +} + +/** + * Subdivides a polygon to a given granularity. Intended for preprocessing geometry for the 'fill' and 'fill-extrusion' layer types. + * All returned triangles have the counter-clockwise winding order. + * @param polygon - An array of point rings that specify the polygon. The first ring is the polygon exterior, all subsequent rings form holes inside the first ring. + * @param canonical - The canonical tile ID of the tile this polygon belongs to. Needed for generating special geometry for tiles that border the poles. + * @param granularity - The subdivision granularity. If we assume tile EXTENT=8192, then a granularity of 2 will result in geometry being "cut" on each axis + * divisible by 4096 (including outside the tile range, so -8192, -4096, or 12288...), granularity of 8 on axes divisible by 1024 and so on. + * Granularity of 1 or lower results in *no* subdivision. + * @param generateOutlineLines - When true, also generates index arrays for subdivided lines that form the outline of the supplied polygon. True by default. + * @returns An object that contains the generated vertex array, triangle index array and, if specified, line index arrays. + */ +export function subdividePolygon(polygon: Array>, canonical: CanonicalTileID, granularity: number, generateOutlineLines: boolean = true): SubdivisionResult { + const subdivider = new Subdivider(granularity, canonical); + return subdivider.subdividePolygonInternal(polygon, generateOutlineLines); +} + +/** + * Subdivides a line represented by an array of points. Mainly intended for preprocessing geometry for the 'line' layer type. + * Assumes a line segment between each two consecutive points in the array. + * Does not assume a line segment from last point to first point, unless `isRing` is set to `true`. + * For example, an array of 4 points describes exactly 3 line segments. + * @param linePoints - An array of points describing the line segments. + * @param granularity - Subdivision granularity. + * @param isRing - When true, an additional line segment is assumed to exist between the input array's last and first point. + * @returns A new array of points of the subdivided line segments. The array may contain some of the original Point objects. If `isRing` is set to `true`, then this also includes the (subdivided) segment from the last point of the input array to the first point. + * + * @example + * ```ts + * const result = subdivideVertexLine([ + * new Point(0, 0), + * new Point(8, 0), + * new Point(0, 8), + * ], EXTENT / 4, false); + * // Results in an array of points with these (x, y) coordinates: + * // 0, 0 + * // 4, 0 + * // 8, 0 + * // 4, 4 + * // 0, 8 + * ``` + * + * @example + * ```ts + * const result = subdivideVertexLine([ + * new Point(0, 0), + * new Point(8, 0), + * new Point(0, 8), + * ], EXTENT / 4, true); + * // Results in an array of points with these (x, y) coordinates: + * // 0, 0 + * // 4, 0 + * // 8, 0 + * // 4, 4 + * // 0, 8 + * // 0, 4 + * // 0, 0 + * ``` + */ +export function subdivideVertexLine(linePoints: Array, granularity: number, isRing: boolean = false): Array { + if (!linePoints || linePoints.length < 1) { + return []; + } + + if (linePoints.length < 2) { + return []; + } + + // Generate an extra line segment between the input array's first and last points, + // but only if isRing=true AND the first and last points actually differ. + const first = linePoints[0]; + const last = linePoints[linePoints.length - 1]; + const addLastToFirstSegment = isRing && (first.x !== last.x || first.y !== last.y); + + if (granularity < 2) { + if (addLastToFirstSegment) { + return [...linePoints, linePoints[0]]; + } else { + return [...linePoints]; + } + } + + const cellSize = Math.floor(EXTENT / granularity); + const finalLineVertices: Array = []; + + finalLineVertices.push(new Point(linePoints[0].x, linePoints[0].y)); + + // Iterate over all input lines + const totalPoints = linePoints.length; + const lastIndex = addLastToFirstSegment ? totalPoints : (totalPoints - 1); + for (let pointIndex = 0; pointIndex < lastIndex; pointIndex++) { + const linePoint0 = linePoints[pointIndex]; + const linePoint1 = pointIndex < (totalPoints - 1) ? linePoints[pointIndex + 1] : linePoints[0]; + const lineVertex0x = linePoint0.x; + const lineVertex0y = linePoint0.y; + const lineVertex1x = linePoint1.x; + const lineVertex1y = linePoint1.y; + + const dirXnonZero = lineVertex0x !== lineVertex1x; + const dirYnonZero = lineVertex0y !== lineVertex1y; + + if (!dirXnonZero && !dirYnonZero) { + continue; + } + + const dirX = lineVertex1x - lineVertex0x; + const dirY = lineVertex1y - lineVertex0y; + const absDirX = Math.abs(dirX); + const absDirY = Math.abs(dirY); + + let lastPointX = lineVertex0x; + let lastPointY = lineVertex0y; + + // Walk along the line segment from start to end. In every step, + // find out the distance from start until the line intersects either the X-parallel or Y-parallel subdivision axis. + // Pick the closer intersection, add it to the final line points and consider that point the new start of the line. + // But also make sure the intersection point does not lie beyond the end of the line. + // If none of the intersection points is closer than line end, add the endpoint to the final line and break the loop. + + while (true) { + const nextBoundaryX = dirX > 0 ? + ((Math.floor(lastPointX / cellSize) + 1) * cellSize) : + ((Math.ceil(lastPointX / cellSize) - 1) * cellSize); + const nextBoundaryY = dirY > 0 ? + ((Math.floor(lastPointY / cellSize) + 1) * cellSize) : + ((Math.ceil(lastPointY / cellSize) - 1) * cellSize); + const axisDistanceToBoundaryX = Math.abs(lastPointX - nextBoundaryX); + const axisDistanceToBoundaryY = Math.abs(lastPointY - nextBoundaryY); + + const axisDistanceToEndX = Math.abs(lastPointX - lineVertex1x); + const axisDistanceToEndY = Math.abs(lastPointY - lineVertex1y); + + const realDistanceToBoundaryX = dirXnonZero ? axisDistanceToBoundaryX / absDirX : Number.POSITIVE_INFINITY; + const realDistanceToBoundaryY = dirYnonZero ? axisDistanceToBoundaryY / absDirY : Number.POSITIVE_INFINITY; + + if ((axisDistanceToEndX <= axisDistanceToBoundaryX || !dirXnonZero) && + (axisDistanceToEndY <= axisDistanceToBoundaryY || !dirYnonZero)) { + break; + } + + if ((realDistanceToBoundaryX < realDistanceToBoundaryY && dirXnonZero) || !dirYnonZero) { + // We hit the X cell boundary first + // Always consider the X cell hit if Y dir is zero + lastPointX = nextBoundaryX; + lastPointY = lastPointY + dirY * realDistanceToBoundaryX; + const next = new Point(lastPointX, Math.round(lastPointY)); + + // Do not add the next vertex if it is equal to the last added vertex + if (finalLineVertices[finalLineVertices.length - 1].x !== next.x || + finalLineVertices[finalLineVertices.length - 1].y !== next.y) { + finalLineVertices.push(next); + } + } else { + lastPointX = lastPointX + dirX * realDistanceToBoundaryY; + lastPointY = nextBoundaryY; + const next = new Point(Math.round(lastPointX), lastPointY); + + if (finalLineVertices[finalLineVertices.length - 1].x !== next.x || + finalLineVertices[finalLineVertices.length - 1].y !== next.y) { + finalLineVertices.push(next); + } + } + } + + const last = new Point(lineVertex1x, lineVertex1y); + if (finalLineVertices[finalLineVertices.length - 1].x !== last.x || + finalLineVertices[finalLineVertices.length - 1].y !== last.y) { + finalLineVertices.push(last); + } + } + + return finalLineVertices; +} + +/** + * Takes a polygon as an array of point rings, returns a flattened array of the X,Y coordinates of these points. + * Also creates an array of hole indices. Both returned arrays are required for `earcut`. + */ +function flatten(polygon: Array>): { + flattened: Array; + holeIndices: Array; +} { + const holeIndices = []; + const flattened = []; + + for (const ring of polygon) { + if (ring.length === 0) { + continue; + } + + if (ring !== polygon[0]) { + holeIndices.push(flattened.length / 2); + } + + for (let i = 0; i < ring.length; i++) { + flattened.push(ring[i].x); + flattened.push(ring[i].y); + } + } + + return { + flattened, + holeIndices + }; +} + +/** + * Returns a new array of indices where all triangles have the counter-clockwise winding order. + * @param flattened - Flattened vertex buffer. + * @param indices - Triangle indices. + */ +export function fixWindingOrder(flattened: Array, indices: Array): Array { + const corrected = []; + + for (let i = 0; i < indices.length; i += 3) { + const i0 = indices[i]; + const i1 = indices[i + 1]; + const i2 = indices[i + 2]; + + const v0x = flattened[i0 * 2]; + const v0y = flattened[i0 * 2 + 1]; + const v1x = flattened[i1 * 2]; + const v1y = flattened[i1 * 2 + 1]; + const v2x = flattened[i2 * 2]; + const v2y = flattened[i2 * 2 + 1]; + + const e0x = v1x - v0x; + const e0y = v1y - v0y; + const e1x = v2x - v0x; + const e1y = v2y - v0y; + + const crossProduct = e0x * e1y - e0y * e1x; + + if (crossProduct > 0) { + // Flip + corrected.push(i0); + corrected.push(i2); + corrected.push(i1); + } else { + // Don't flip + corrected.push(i0); + corrected.push(i1); + corrected.push(i2); + } + } + + return corrected; +} + +/** + * Triangulates a ring of vertex indices. Appends to the supplied array of final triangle indices. + * @param vertexBuffer - Flattened vertex coordinate array. + * @param ring - Ordered ring of vertex indices to triangulate. + * @param leftmostIndex - The index of the leftmost vertex in the supplied ring. + * @param finalIndices - Array of final triangle indices, into where the resulting triangles are appended. + */ +export function scanlineTriangulateVertexRing(vertexBuffer: Array, ring: Array, finalIndices: Array): void { + // Triangulate the ring + // It is guaranteed to be convex and ordered + if (ring.length === 0) { + throw new Error('Subdivision vertex ring is empty.'); + } + + // Find the leftmost vertex in the ring + let leftmostIndex = 0; + let leftmostX = vertexBuffer[ring[0] * 2]; + for (let i = 1; i < ring.length; i++) { + const x = vertexBuffer[ring[i] * 2]; + if (x < leftmostX) { + leftmostX = x; + leftmostIndex = i; + } + } + + // Traverse the ring in both directions from the leftmost vertex + // Assume ring is in CCW order (to produce CCW triangles) + const ringVertexLength = ring.length; + let lastEdgeA = leftmostIndex; + let lastEdgeB = (lastEdgeA + 1) % ringVertexLength; + + while (true) { + const candidateIndexA = (lastEdgeA - 1) >= 0 ? (lastEdgeA - 1) : (ringVertexLength - 1); + const candidateIndexB = (lastEdgeB + 1) % ringVertexLength; + + // Pick candidate, move edge + const candidateAx = vertexBuffer[ring[candidateIndexA] * 2]; + const candidateAy = vertexBuffer[ring[candidateIndexA] * 2 + 1]; + const candidateBx = vertexBuffer[ring[candidateIndexB] * 2]; + const candidateBy = vertexBuffer[ring[candidateIndexB] * 2 + 1]; + const lastEdgeAx = vertexBuffer[ring[lastEdgeA] * 2]; + const lastEdgeAy = vertexBuffer[ring[lastEdgeA] * 2 + 1]; + const lastEdgeBx = vertexBuffer[ring[lastEdgeB] * 2]; + const lastEdgeBy = vertexBuffer[ring[lastEdgeB] * 2 + 1]; + + let pickA = false; + + if (candidateAx < candidateBx) { + pickA = true; + } else if (candidateAx > candidateBx) { + pickA = false; + } else { + // Pick the candidate that is more "right" of the last edge's line + const ex = lastEdgeBx - lastEdgeAx; + const ey = lastEdgeBy - lastEdgeAy; + const nx = ey; + const ny = -ex; + const sign = (lastEdgeAy < lastEdgeBy) ? 1 : -1; + // dot( (candidateA <-- lastEdgeA), normal ) + const aRight = ((candidateAx - lastEdgeAx) * nx + (candidateAy - lastEdgeAy) * ny) * sign; + // dot( (candidateB <-- lastEdgeA), normal ) + const bRight = ((candidateBx - lastEdgeAx) * nx + (candidateBy - lastEdgeAy) * ny) * sign; + if (aRight > bRight) { + pickA = true; + } + } + + if (pickA) { + // Pick candidate A + const c = ring[candidateIndexA]; + const a = ring[lastEdgeA]; + const b = ring[lastEdgeB]; + if (c !== a && c !== b && a !== b) { + finalIndices.push(b, a, c); + } + lastEdgeA--; + if (lastEdgeA < 0) { + lastEdgeA = ringVertexLength - 1; + } + } else { + // Pick candidate B + const c = ring[candidateIndexB]; + const a = ring[lastEdgeA]; + const b = ring[lastEdgeB]; + if (c !== a && c !== b && a !== b) { + finalIndices.push(b, a, c); + } + lastEdgeB++; + if (lastEdgeB >= ringVertexLength) { + lastEdgeB = 0; + } + } + + if (candidateIndexA === candidateIndexB) { + break; // We ran out of ring vertices + } + } +} diff --git a/src/render/subdivision_granularity_settings.ts b/src/render/subdivision_granularity_settings.ts new file mode 100644 index 0000000000..07f4e91f52 --- /dev/null +++ b/src/render/subdivision_granularity_settings.ts @@ -0,0 +1,112 @@ +// Should match actual possible granularity settings from circle_bucket.ts + +/** + * Defines the granularity of subdivision for circles with `circle-pitch-alignment: 'map'` and for heatmap kernels. + * More subdivision will cause circles to more closely follow the planet's surface. + * + * Possible values: 1, 3, 5, 7. + * Subdivision of 1 results in a simple quad. + */ +export type CircleGranularity = 1 | 3 | 5 | 7; + +/** + * Controls how much subdivision happens for a given type of geometry at different zoom levels. + */ +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) { + if (minGranularity > baseZoomGranularity) { + throw new Error('Min granularity must not be greater than base granularity.'); + } + + 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, 1); + } +} + +/** + * An object describing how much subdivision should be applied to different types of geometry at different zoom levels. + */ +export class SubdivisionGranularitySetting { + /** + * Granularity settings used for fill and fill-extrusion layers (for fill, both polygons and their anti-aliasing outlines). + */ + public readonly fill: SubdivisionGranularityExpression; + + /** + * Granularity used for the line layer. + */ + public readonly line: SubdivisionGranularityExpression; + + /** + * Granularity used for geometry covering the entire tile: raster tiles, etc. + */ + public readonly tile: SubdivisionGranularityExpression; + + /** + * Granularity used for stencil masks for tiles. + */ + public readonly stencil: SubdivisionGranularityExpression; + + /** + * Controls the granularity of `pitch-alignment: map` circles and heatmap kernels. + * More granular circles will more closely follow the map's surface. + */ + public readonly circle: CircleGranularity; + + constructor(options: { + /** + * Granularity settings used for fill and fill-extrusion layers (for fill, both polygons and their anti-aliasing outlines). + */ + fill: SubdivisionGranularityExpression; + /** + * Granularity used for the line layer. + */ + line: SubdivisionGranularityExpression; + /** + * Granularity used for geometry covering the entire tile: stencil masks, raster tiles, etc. + */ + tile: SubdivisionGranularityExpression; + /** + * Granularity used for stencil masks for tiles. + */ + stencil: SubdivisionGranularityExpression; + /** + * Controls the granularity of `pitch-alignment: map` circles and heatmap kernels. + * More granular circles will more closely follow the map's surface. + */ + circle: CircleGranularity; + }) { + this.fill = options.fill; + this.line = options.line; + this.tile = options.tile; + this.stencil = options.stencil; + this.circle = options.circle; + } + + /** + * Granularity settings that disable subdivision altogether. + */ + public static readonly noSubdivision = new SubdivisionGranularitySetting({ + fill: new SubdivisionGranularityExpression(0, 0), + line: new SubdivisionGranularityExpression(0, 0), + tile: new SubdivisionGranularityExpression(0, 0), + stencil: new SubdivisionGranularityExpression(0, 0), + circle: 1 + }); +} diff --git a/src/render/terrain.ts b/src/render/terrain.ts index 2329caeacf..2289391d01 100644 --- a/src/render/terrain.ts +++ b/src/render/terrain.ts @@ -319,9 +319,9 @@ export class Terrain { } /** - * Reads a pixel from the coords-framebuffer and translate this to mercator. + * Reads a pixel from the coords-framebuffer and translate this to mercator, or null, if the pixel doesn't lie on the terrain's surface (but the sky instead). * @param p - Screen-Coordinate - * @returns mercator coordinate for a screen pixel + * @returns Mercator coordinate for a screen pixel, or null, if the pixel is not covered by terrain (is in the sky). */ pointCoordinate(p: Point): MercatorCoordinate { // First, ensure the coords framebuffer is up to date. @@ -341,7 +341,11 @@ export class Terrain { const y = rgba[1] + ((rgba[2] & 15) << 8); const tileID = this.coordsIndex[255 - rgba[3]]; const tile = tileID && this.sourceCache.getTileByID(tileID); - if (!tile) return null; + + if (!tile) { + return null; + } + const coordsSize = this._coordsTextureSize; const worldSize = (1 << tile.tileID.canonical.z) * coordsSize; return new MercatorCoordinate( @@ -386,7 +390,7 @@ export class Terrain { indexArray.emplaceBack(x + y, meshSize + x + y + 1, meshSize + x + y + 2); indexArray.emplaceBack(x + y, meshSize + x + y + 2, x + y + 1); } - // add an extra frame around the mesh to avoid stiching on tile boundaries with different zoomlevels + // add an extra frame around the mesh to avoid stitching on tile boundaries with different zoomlevels // first code-block is for top-bottom frame and second for left-right frame const offsetTop = vertexArray.length, offsetBottom = offsetTop + (meshSize + 1) * 2; for (const y of [0, 1]) for (let x = 0; x <= meshSize; x++) for (const z of [0, 1]) @@ -415,7 +419,7 @@ export class Terrain { } /** - * Calculates a height of the frame around the terrain-mesh to avoid stiching between + * Calculates a height of the frame around the terrain-mesh to avoid stitching between * tile boundaries in different zoomlevels. * @param zoom - current zoomlevel * @returns the elevation delta in meters diff --git a/src/render/texture.ts b/src/render/texture.ts index 99ce6a335f..2ae87e37b5 100644 --- a/src/render/texture.ts +++ b/src/render/texture.ts @@ -1,8 +1,9 @@ import type {Context} from '../gl/context'; import type {RGBAImage, AlphaImage} from '../util/image'; -import {isImageBitmap} from '../util/util'; +import {extend, isImageBitmap} from '../util/util'; -export type TextureFormat = WebGLRenderingContextBase['RGBA'] | WebGLRenderingContextBase['ALPHA']; +export type TextureFormatWebGL2 = WebGL2RenderingContextBase['RG8'] | WebGL2RenderingContextBase['R8'] +export type TextureFormat = WebGLRenderingContextBase['RGBA'] |WebGLRenderingContextBase['RGB'] | WebGLRenderingContextBase['ALPHA'] | WebGLRenderingContextBase['LUMINANCE'] | TextureFormatWebGL2; export type TextureFilter = WebGLRenderingContextBase['LINEAR'] | WebGLRenderingContextBase['LINEAR_MIPMAP_NEAREST'] | WebGLRenderingContextBase['NEAREST']; export type TextureWrap = WebGLRenderingContextBase['REPEAT'] | WebGLRenderingContextBase['CLAMP_TO_EDGE'] | WebGLRenderingContextBase['MIRRORED_REPEAT']; @@ -35,12 +36,18 @@ export class Texture { this.context = context; this.format = format; this.texture = context.gl.createTexture(); - this.update(image, options); + + // Pass format to the update method to enforce its usage + this.update(image, options ? extend(options, {format}) : {format}); } + /** + * Updates texture content, can also change texture format if necessary + */ update(image: TextureImage, options?: { premultiply?: boolean; useMipmap?: boolean; + format?:TextureFormat; } | null, position?: { x: number; y: number; @@ -51,27 +58,36 @@ export class Texture { const {gl} = context; this.useMipmap = Boolean(options && options.useMipmap); + + // Use default maplibre format gl.RGBA to remain compatible with all users of the Texture class + const newFormat = options && options.format ? options.format : gl.RGBA; + const formatChanged = this.format !== newFormat; + this.format = newFormat; + gl.bindTexture(gl.TEXTURE_2D, this.texture); context.pixelStoreUnpackFlipY.set(false); context.pixelStoreUnpack.set(1); context.pixelStoreUnpackPremultiplyAlpha.set(this.format === gl.RGBA && (!options || options.premultiply !== false)); - if (resize) { + // Since internal-format and format can be represented by different values (e.g. gl.RG8 vs gl.RG) in WebGL2, we need to preform conversion + const format = this.textureFormatFromInternalFormat(this.format); + + if (resize || formatChanged) { this.size = [width, height]; if (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || image instanceof HTMLVideoElement || image instanceof ImageData || isImageBitmap(image)) { - gl.texImage2D(gl.TEXTURE_2D, 0, this.format, this.format, gl.UNSIGNED_BYTE, image); + gl.texImage2D(gl.TEXTURE_2D, 0, this.format, format, gl.UNSIGNED_BYTE, image); } else { - gl.texImage2D(gl.TEXTURE_2D, 0, this.format, width, height, 0, this.format, gl.UNSIGNED_BYTE, (image as DataTextureImage).data); + gl.texImage2D(gl.TEXTURE_2D, 0, this.format, width, height, 0, format, gl.UNSIGNED_BYTE, (image as DataTextureImage).data); } } else { const {x, y} = position || {x: 0, y: 0}; if (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || image instanceof HTMLVideoElement || image instanceof ImageData || isImageBitmap(image)) { - gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, format, gl.UNSIGNED_BYTE, image); } else { - gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, (image as DataTextureImage).data); + gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, width, height, format, gl.UNSIGNED_BYTE, (image as DataTextureImage).data); } } @@ -111,4 +127,26 @@ export class Texture { gl.deleteTexture(this.texture); this.texture = null; } + + /** + * Method for accessing texture format by its internal format for cases, when these two are not the same + * - specifically for special WebGL2 formats + */ + textureFormatFromInternalFormat(internalFormat: TextureFormat) { + let format: GLenum = internalFormat; + + if (!WebGL2RenderingContext) { + return format; + } + + switch (internalFormat) { + case WebGL2RenderingContext['RG8']: + format = WebGL2RenderingContext['RG']; + break; + case WebGL2RenderingContext['R8']: + format = WebGL2RenderingContext['RED']; + break; + } + return format; + } } diff --git a/src/shaders/_prelude.vertex.glsl b/src/shaders/_prelude.vertex.glsl index 874f95fe4f..e3eb359188 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..77f3a77dc8 --- /dev/null +++ b/src/shaders/_projection_globe.vertex.glsl @@ -0,0 +1,131 @@ +#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; + } +} + +// 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; +} + +// Unlike interpolateProjection, this variant of the function preserves the Z value of the final vector. +vec4 interpolateProjectionFor3D(vec2 posInTile, vec3 spherePos, float elevation) { + vec3 elevatedPos = spherePos * (1.0 + elevation / GLOBE_RADIUS); + vec4 globePosition = u_projection_matrix * vec4(elevatedPos, 1.0); + vec4 fallbackPosition = u_projection_fallback_matrix * vec4(posInTile, elevation, 1.0); + return mix(fallbackPosition, globePosition, u_projection_transition); +} + +// 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); +} + +// Projects the tile coordinates+elevation while **preserving Z** value from multiplication with 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..bf216d3556 --- /dev/null +++ b/src/shaders/_projection_mercator.vertex.glsl @@ -0,0 +1,31 @@ +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); +} + +vec4 projectTileFor3D(vec2 posInTile, float elevation) { + // In globe the `projectTileWithElevation` and `projectTileFor3D` functions differ + // on what Z value they output. There is no need for this in mercator though, + // thus here they are the same function. + return projectTileWithElevation(posInTile, elevation); +} diff --git a/src/shaders/atmosphere.fragment.glsl b/src/shaders/atmosphere.fragment.glsl new file mode 100644 index 0000000000..813b16de1e --- /dev/null +++ b/src/shaders/atmosphere.fragment.glsl @@ -0,0 +1,168 @@ +in vec3 view_direction; + +uniform vec3 u_sun_pos; +uniform vec3 u_globe_position; +uniform float u_globe_radius; +uniform float u_atmosphere_blend; + +/* + * Shader use from https://github.com/wwwtyro/glsl-atmosphere + * Made some change to adapt to MapLibre Globe geometry + */ + +const float PI = 3.141592653589793; +const int iSteps = 5; +const int jSteps = 3; + +/* radius of the planet */ +const float EARTH_RADIUS = 6371e3; +/* radius of the atmosphere */ +const float ATMOS_RADIUS = 6471e3; + +vec2 rsi(vec3 r0, vec3 rd, float sr) { + // ray-sphere intersection that assumes + // the sphere is centered at the origin. + // No intersection when result.x > result.y + float a = dot(rd, rd); + float b = 2.0 * dot(rd, r0); + float c = dot(r0, r0) - (sr * sr); + float d = (b*b) - 4.0*a*c; + if (d < 0.0) return vec2(1e5,-1e5); + return vec2( + (-b - sqrt(d))/(2.0*a), + (-b + sqrt(d))/(2.0*a) + ); +} + + +vec4 atmosphere(vec3 r, vec3 r0, vec3 pSun, float iSun, float rPlanet, float rAtmos, vec3 kRlh, float kMie, float shRlh, float shMie, float g) { + // Normalize the sun and view directions. + pSun = normalize(pSun); + r = normalize(r); + + // Calculate the step size of the primary ray. + vec2 p = rsi(r0, r, rAtmos); + if (p.x > p.y) return vec4(0,0,0,0); + + if (p.x < 0.0) { + p.x = 0.0; + } + + vec3 pos = r0 + r * p.x; + + vec2 p2 = rsi(r0, r, rPlanet); + if (p2.x <= p2.y && p2.x > 0.0) { + p.y = min(p.y, p2.x); + } + float iStepSize = (p.y - p.x) / float(iSteps); + + // Initialize the primary ray time. + float iTime = p.x + iStepSize * 0.5; + + // Initialize accumulators for Rayleigh and Mie scattering. + vec3 totalRlh = vec3(0,0,0); + vec3 totalMie = vec3(0,0,0); + + // Initialize optical depth accumulators for the primary ray. + float iOdRlh = 0.0; + float iOdMie = 0.0; + + // Calculate the Rayleigh and Mie phases. + float mu = dot(r, pSun); + float mumu = mu * mu; + float gg = g * g; + float pRlh = 3.0 / (16.0 * PI) * (1.0 + mumu); + float pMie = 3.0 / (8.0 * PI) * ((1.0 - gg) * (mumu + 1.0)) / (pow(1.0 + gg - 2.0 * mu * g, 1.5) * (2.0 + gg)); + + // Sample the primary ray. + for (int i = 0; i < iSteps; i++) { + + // Calculate the primary ray sample position. + vec3 iPos = r0 + r * iTime; + + // Calculate the height of the sample. + float iHeight = length(iPos) - rPlanet; + + // Calculate the optical depth of the Rayleigh and Mie scattering for this step. + float odStepRlh = exp(-iHeight / shRlh) * iStepSize; + float odStepMie = exp(-iHeight / shMie) * iStepSize; + + // Accumulate optical depth. + iOdRlh += odStepRlh; + iOdMie += odStepMie; + + // Calculate the step size of the secondary ray. + float jStepSize = rsi(iPos, pSun, rAtmos).y / float(jSteps); + + // Initialize the secondary ray time. + float jTime = jStepSize * 0.5; + + // Initialize optical depth accumulators for the secondary ray. + float jOdRlh = 0.0; + float jOdMie = 0.0; + + // Sample the secondary ray. + for (int j = 0; j < jSteps; j++) { + + // Calculate the secondary ray sample position. + vec3 jPos = iPos + pSun * jTime; + + // Calculate the height of the sample. + float jHeight = length(jPos) - rPlanet; + + // Accumulate the optical depth. + jOdRlh += exp(-jHeight / shRlh) * jStepSize; + jOdMie += exp(-jHeight / shMie) * jStepSize; + + // Increment the secondary ray time. + jTime += jStepSize; + } + + // Calculate attenuation. + vec3 attn = exp(-(kMie * (iOdMie + jOdMie) + kRlh * (iOdRlh + jOdRlh))); + + // Accumulate scattering. + totalRlh += odStepRlh * attn; + totalMie += odStepMie * attn; + + // Increment the primary ray time. + iTime += iStepSize; + } + + // Calculate opacity + //float opacity = exp(-(length(kRlh) * iOdRlh + kMie * iOdMie)); + float opacity = min(0.75, exp(-(length(kRlh) * length(totalRlh) + kMie * length(totalMie)))); + + // Calculate the final color. + vec3 color = iSun * (pRlh * kRlh * totalRlh + pMie * kMie * totalMie); + + return vec4(color, opacity); +} + +void main() { + // The globe is small compare to real Earth. + // To still have a correct atmosphere rendering, we scale the whole world to the EARTH_RADIUS. + // Change camera position accordingly. + vec3 scale_camera_pos = -u_globe_position * EARTH_RADIUS / u_globe_radius; + + vec4 color = atmosphere( + normalize(view_direction), // ray direction + scale_camera_pos, // ray origin + u_sun_pos, // position of the sun + 22.0, // intensity of the sun + EARTH_RADIUS, // radius of the planet in meters + ATMOS_RADIUS, // radius of the atmosphere in meters + vec3(5.5e-6, 13.0e-6, 22.4e-6), // Rayleigh scattering coefficient + 21e-6, // Mie scattering coefficient + 8e3, // Rayleigh scale height + 1.2e3, // Mie scale height + 0.758 // Mie preferred scattering direction + ); + + // Apply exposure. + color.xyz = 1.0 - exp(-1.0 * color.xyz); + + vec4 no_effect_color = vec4(0, 0, 0, 0); + + gl_FragColor = mix(color, no_effect_color, 1.0 - u_atmosphere_blend); +} diff --git a/src/shaders/atmosphere.vertex.glsl b/src/shaders/atmosphere.vertex.glsl new file mode 100644 index 0000000000..73432948db --- /dev/null +++ b/src/shaders/atmosphere.vertex.glsl @@ -0,0 +1,11 @@ +in vec2 a_pos; + +uniform mat4 u_inv_proj_matrix; + +out vec3 view_direction; + +void main() { + // Compute each camera ray + view_direction = (u_inv_proj_matrix * vec4(a_pos, 0.0, 1.0)).xyz; + gl_Position = vec4(a_pos, 0.0, 1.0); +} diff --git a/src/shaders/background.vertex.glsl b/src/shaders/background.vertex.glsl index 46f9eaa124..b7eb3b2d6c 100644 --- a/src/shaders/background.vertex.glsl +++ b/src/shaders/background.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/background_pattern.vertex.glsl b/src/shaders/background_pattern.vertex.glsl index 90a7af24f7..7ac08dc667 100644 --- a/src/shaders/background_pattern.vertex.glsl +++ b/src/shaders/background_pattern.vertex.glsl @@ -1,4 +1,3 @@ -uniform mat4 u_matrix; uniform vec2 u_pattern_size_a; uniform vec2 u_pattern_size_b; uniform vec2 u_pixel_coord_upper; @@ -12,7 +11,7 @@ out vec2 v_pos_a; out vec2 v_pos_b; void main() { - gl_Position = u_matrix * vec4(a_pos, 0, 1); + gl_Position = projectTile(a_pos); v_pos_a = get_pattern_pos(u_pixel_coord_upper, u_pixel_coord_lower, u_scale_a * u_pattern_size_a, u_tile_units_to_pixels, a_pos); v_pos_b = get_pattern_pos(u_pixel_coord_upper, u_pixel_coord_lower, u_scale_b * u_pattern_size_b, u_tile_units_to_pixels, a_pos); diff --git a/src/shaders/circle.fragment.glsl b/src/shaders/circle.fragment.glsl index 09c67c689c..85d56a1ec8 100644 --- a/src/shaders/circle.fragment.glsl +++ b/src/shaders/circle.fragment.glsl @@ -28,6 +28,18 @@ void main() { fragColor = v_visibility * opacity_t * mix(color * opacity, stroke_color * stroke_opacity, color_t); + const float epsilon = 0.5 / 255.0; + if (fragColor.r < epsilon && fragColor.g < epsilon && fragColor.b < epsilon && fragColor.a < epsilon) { + // If this pixel wouldn't affect the framebuffer contents in any way, discard it for performance. + // This disables early-Z test, but that is likely irrelevant for circles, performance wise. + // But many circles might put a lot of load on the blending and framebuffer output hardware due to using a lot of pixels, + // and this discard will help in that case. + // Also, each circle will at most use ~3/4 of its rasterized pixels, due to being a circle approximated with a square, + // this will discard the unused 1/4. + // Also note that this discard happens even if overdraw inspection is enabled - because discarded pixels never contribute to overdraw. + discard; + } + #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); #endif diff --git a/src/shaders/circle.vertex.glsl b/src/shaders/circle.vertex.glsl index c52defb644..809db12dd4 100644 --- a/src/shaders/circle.vertex.glsl +++ b/src/shaders/circle.vertex.glsl @@ -1,9 +1,10 @@ -uniform mat4 u_matrix; uniform bool u_scale_with_map; uniform bool u_pitch_with_map; uniform vec2 u_extrude_scale; +uniform highp float u_globe_extrude_scale; uniform lowp float u_device_pixel_ratio; uniform highp float u_camera_to_center_distance; +uniform vec2 u_translate; in vec2 a_pos; @@ -28,29 +29,59 @@ void main(void) { #pragma mapbox: initialize lowp float stroke_opacity // decode the extrusion vector that we snuck into the a_pos vector - vec2 extrude = vec2(mod(a_pos, 2.0) * 2.0 - 1.0); + vec2 pos_raw = a_pos + 32768.0; + vec2 extrude = vec2(mod(pos_raw, 8.0) / 7.0 * 2.0 - 1.0); - // multiply a_pos by 0.5, since we had it * 2 in order to sneak + // Divide a_pos by 8, since we had it * 8 in order to sneak // in extrusion data - vec2 circle_center = floor(a_pos * 0.5); + vec2 circle_center = floor(pos_raw / 8.0) + u_translate; float ele = get_elevation(circle_center); - v_visibility = calculate_visibility(u_matrix * vec4(circle_center, ele, 1.0)); + v_visibility = calculate_visibility(projectTileWithElevation(circle_center, ele)); if (u_pitch_with_map) { +#ifdef GLOBE + vec3 center_vector = projectToSphere(circle_center); +#endif + + // This var is only used when globe is enabled and defined. + float angle_scale = u_globe_extrude_scale; + + // Keep track of "2D" corner position to allow smooth interpolation between globe and mercator vec2 corner_position = circle_center; if (u_scale_with_map) { - corner_position += extrude * (radius + stroke_width) * u_extrude_scale; + angle_scale *= (radius + stroke_width); + corner_position += extrude * u_extrude_scale * (radius + stroke_width); } else { // Pitching the circle with the map effectively scales it with the map // To counteract the effect for pitch-scale: viewport, we rescale the // whole circle based on the pitch scaling effect at its central point - vec4 projected_center = u_matrix * vec4(circle_center, 0, 1); - corner_position += extrude * (radius + stroke_width) * u_extrude_scale * (projected_center.w / u_camera_to_center_distance); +#ifdef GLOBE + vec4 projected_center = interpolateProjection(circle_center, center_vector, ele); +#else + vec4 projected_center = projectTileWithElevation(circle_center, ele); +#endif + corner_position += extrude * u_extrude_scale * (radius + stroke_width) * (projected_center.w / u_camera_to_center_distance); + angle_scale *= (radius + stroke_width) * (projected_center.w / u_camera_to_center_distance); } - gl_Position = u_matrix * vec4(corner_position, ele, 1); +#ifdef GLOBE + vec2 angles = extrude * angle_scale; + vec3 corner_vector = globeRotateVector(center_vector, angles); + gl_Position = interpolateProjection(corner_position, corner_vector, ele); +#else + gl_Position = projectTileWithElevation(corner_position, ele); +#endif } else { - gl_Position = u_matrix * vec4(circle_center, ele, 1); + gl_Position = projectTileWithElevation(circle_center, ele); + + if (gl_Position.z / gl_Position.w > 1.0) { + // Same as in fill_outline.fragment.glsl and line.fragment.glsl, we need to account for some hardware + // doing glFragDepth and clipping in the wrong order by doing clipping manually in the shader. + // For screenspace (not u_pitch_with_map) circles, it is enough to detect whether the anchor + // point should be clipped here in the vertex shader, and clip it by moving in beyond the + // renderable range -1..1 in X and Y (moving it to 10000 is more than enough). + gl_Position.xy = vec2(10000.0); + } if (u_scale_with_map) { gl_Position.xy += extrude * (radius + stroke_width) * u_extrude_scale * u_camera_to_center_distance; 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/collision_box.vertex.glsl b/src/shaders/collision_box.vertex.glsl index b6d5957bd8..794bbd7ed0 100644 --- a/src/shaders/collision_box.vertex.glsl +++ b/src/shaders/collision_box.vertex.glsl @@ -2,16 +2,11 @@ in vec2 a_anchor_pos; in vec2 a_placed; in vec2 a_box_real; -uniform mat4 u_matrix; uniform vec2 u_pixel_extrude_scale; out float v_placed; out float v_notUsed; -vec4 projectTileWithElevation(vec2 posInTile, float elevation) { - return u_matrix * vec4(posInTile, elevation, 1.0); -} - void main() { gl_Position = projectTileWithElevation(a_anchor_pos, get_elevation(a_anchor_pos)); gl_Position.xy = ((a_box_real + 0.5) * u_pixel_extrude_scale * 2.0 - 1.0) * vec2(1.0, -1.0) * gl_Position.w; diff --git a/src/shaders/collision_circle.fragment.glsl b/src/shaders/collision_circle.fragment.glsl index f171b426af..8660d3bc68 100644 --- a/src/shaders/collision_circle.fragment.glsl +++ b/src/shaders/collision_circle.fragment.glsl @@ -1,11 +1,10 @@ in float v_radius; in vec2 v_extrude; -in float v_perspective_ratio; in float v_collision; void main() { - float alpha = 0.5 * min(v_perspective_ratio, 1.0); - float stroke_radius = 0.9 * max(v_perspective_ratio, 1.0); + float alpha = 0.5; + float stroke_radius = 0.9; float distance_to_center = length(v_extrude); float distance_to_edge = abs(distance_to_center - v_radius); diff --git a/src/shaders/collision_circle.vertex.glsl b/src/shaders/collision_circle.vertex.glsl index 7d5f1e215f..8ea3851eae 100644 --- a/src/shaders/collision_circle.vertex.glsl +++ b/src/shaders/collision_circle.vertex.glsl @@ -2,30 +2,13 @@ in vec2 a_pos; in float a_radius; in vec2 a_flags; -uniform mat4 u_matrix; -uniform mat4 u_inv_matrix; uniform vec2 u_viewport_size; -uniform float u_camera_to_center_distance; out float v_radius; out vec2 v_extrude; -out float v_perspective_ratio; out float v_collision; -vec3 toTilePosition(vec2 screenPos) { - // Shoot a ray towards the ground to reconstruct the depth-value - vec4 rayStart = u_inv_matrix * vec4(screenPos, -1.0, 1.0); - vec4 rayEnd = u_inv_matrix * vec4(screenPos, 1.0, 1.0); - - rayStart.xyz /= rayStart.w; - rayEnd.xyz /= rayEnd.w; - - highp float t = (0.0 - rayStart.z) / (rayEnd.z - rayStart.z); - return mix(rayStart.xyz, rayEnd.xyz, t); -} - void main() { - vec2 quadCenterPos = a_pos; float radius = a_radius; float collision = a_flags.x; float vertexIdx = a_flags.y; @@ -36,24 +19,12 @@ void main() { vec2 quadVertexExtent = quadVertexOffset * radius; - // Screen position of the quad might have been computed with different camera parameters. - // Transform the point to a proper position on the current viewport - vec3 tilePos = toTilePosition(quadCenterPos); - vec4 clipPos = u_matrix * vec4(tilePos, 1.0); - - highp float camera_to_anchor_distance = clipPos.w; - highp float collision_perspective_ratio = clamp( - 0.5 + 0.5 * (u_camera_to_center_distance / camera_to_anchor_distance), - 0.0, // Prevents oversized near-field circles in pitched/overzoomed tiles - 4.0); - // Apply small padding for the anti-aliasing effect to fit the quad // Note that v_radius and v_extrude are in screen coordinates already float padding_factor = 1.2; v_radius = radius; v_extrude = quadVertexExtent * padding_factor; - v_perspective_ratio = collision_perspective_ratio; v_collision = collision; - gl_Position = vec4(clipPos.xyz / clipPos.w, 1.0) + vec4(quadVertexExtent * padding_factor / u_viewport_size * 2.0, 0.0, 0.0); + gl_Position = vec4((a_pos / u_viewport_size * 2.0 - 1.0) * vec2(1.0, -1.0), 0.0, 1.0) + vec4(quadVertexExtent * padding_factor / u_viewport_size * 2.0, 0.0, 0.0); } diff --git a/src/shaders/debug.vertex.glsl b/src/shaders/debug.vertex.glsl index 57112938e3..e95f11b961 100644 --- a/src/shaders/debug.vertex.glsl +++ b/src/shaders/debug.vertex.glsl @@ -1,12 +1,11 @@ in vec2 a_pos; out vec2 v_uv; -uniform mat4 u_matrix; uniform float u_overlay_scale; void main() { // This vertex shader expects a EXTENT x EXTENT quad, // The UV co-ordinates for the overlay texture can be calculated using that knowledge v_uv = a_pos / 8192.0; - gl_Position = u_matrix * vec4(a_pos * u_overlay_scale, get_elevation(a_pos), 1); + gl_Position = projectTileWithElevation(a_pos * u_overlay_scale, get_elevation(a_pos)); } diff --git a/src/shaders/fill.vertex.glsl b/src/shaders/fill.vertex.glsl index fcafb429de..d4dbddcd4c 100644 --- a/src/shaders/fill.vertex.glsl +++ b/src/shaders/fill.vertex.glsl @@ -1,6 +1,6 @@ -in vec2 a_pos; +uniform vec2 u_fill_translate; -uniform mat4 u_matrix; +in vec2 a_pos; #pragma mapbox: define highp vec4 color #pragma mapbox: define lowp float opacity @@ -9,5 +9,5 @@ void main() { #pragma mapbox: initialize highp vec4 color #pragma mapbox: initialize lowp float opacity - gl_Position = u_matrix * vec4(a_pos, 0, 1); + gl_Position = projectTile(a_pos + u_fill_translate); } diff --git a/src/shaders/fill_extrusion.fragment.glsl b/src/shaders/fill_extrusion.fragment.glsl index c7913c30ee..d9a40ec6ca 100644 --- a/src/shaders/fill_extrusion.fragment.glsl +++ b/src/shaders/fill_extrusion.fragment.glsl @@ -1,9 +1,50 @@ in vec4 v_color; +#ifdef GLOBE + in vec3 v_sphere_pos; + uniform vec3 u_camera_pos_globe; + uniform highp float u_projection_transition; +#endif + void main() { fragColor = v_color; -#ifdef OVERDRAW_INSPECTOR - fragColor = vec4(1.0); -#endif + #ifdef OVERDRAW_INSPECTOR + fragColor = vec4(1.0); + #endif + + #ifdef GLOBE + // We want extruded geometry to be occluded by the planet. + // This would be trivial in any traditional 3D renderer with Z-buffer, + // but not in MapLibre, since Z-buffer is used to mask certain layers + // and optimize overdraw. + // One solution would be to draw the planet into Z-buffer just before + // rendering fill-extrusion layers, but what if another layer + // is drawn after that which makes use of this Z-buffer mask? + // We can't just trash the mask with out own Z values. + // So instead, the "Z-test" against the planet is done here, + // in the pixel shader. + // Luckily the planet is (assumed to be) a perfect sphere, + // so the ray-planet intersection test is quite simple. + // We discard any fragments that are occluded by the planet. + + // Get nearest point along the ray from fragment to camera. + // Remember that planet center is at 0,0,0. + // Also clamp t to not consider intersections that happened behind the ray origin. + vec3 toPlanetCenter = -v_sphere_pos; + vec3 toCameraNormalized = normalize(u_camera_pos_globe - v_sphere_pos); + float t = dot(toPlanetCenter, toCameraNormalized); + vec3 nearest = v_sphere_pos + toCameraNormalized * max(t, 0.0); + + // We want to remove planet occlusion during the animated transition out of globe view. + // Thus we animate the "radius" of the planet sphere used in ray-sphere collision. + // Radius of 1.0 is equal to full size planet (since we raycast against a unit sphere). + // Note that unsquared globeness is intentionally compared to squared distance from planet center, + // (because `dot(nearest, nearest)` returns the squared length of the vector `nearest`) + // effectively using sqrt(globeness) as the planet radius. This is done to make the animation look better. + float distance_to_planet_center_squared = dot(nearest, nearest); + if (distance_to_planet_center_squared < u_projection_transition) { + discard; // Ray intersected the planet. + } + #endif } diff --git a/src/shaders/fill_extrusion.vertex.glsl b/src/shaders/fill_extrusion.vertex.glsl index 69f9e35071..c87e09d239 100644 --- a/src/shaders/fill_extrusion.vertex.glsl +++ b/src/shaders/fill_extrusion.vertex.glsl @@ -1,9 +1,10 @@ -uniform mat4 u_matrix; uniform vec3 u_lightcolor; uniform lowp vec3 u_lightpos; +uniform lowp vec3 u_lightpos_globe; uniform lowp float u_lightintensity; uniform float u_vertical_gradient; uniform lowp float u_opacity; +uniform vec2 u_fill_translate; in vec2 a_pos; in vec4 a_normal_ed; @@ -15,6 +16,10 @@ in vec4 a_normal_ed; out vec4 v_color; +#ifdef GLOBE + out vec3 v_sphere_pos; +#endif + #pragma mapbox: define highp float base #pragma mapbox: define highp float height @@ -28,11 +33,11 @@ void main() { vec3 normal = a_normal_ed.xyz; #ifdef TERRAIN3D - // Raise the "ceiling" of elements by the elevation of the centroid, in meters. + // Raise the "ceiling" of elements by the elevation of the centroid, in meters. float height_terrain3d_offset = get_elevation(a_centroid); - // To avoid having buildings "hang above a slope", create a "basement" - // by lowering the "floor" of ground-level (and below) elements. - // This is in addition to the elevation of the centroid, in meters. + // To avoid having buildings "hang above a slope", create a "basement" + // by lowering the "floor" of ground-level (and below) elements. + // This is in addition to the elevation of the centroid, in meters. float base_terrain3d_offset = height_terrain3d_offset - (base > 0.0 ? 0.0 : 10.0); #else float height_terrain3d_offset = 0.0; @@ -44,8 +49,17 @@ void main() { height = max(0.0, height) + height_terrain3d_offset; float t = mod(normal.x, 2.0); - - gl_Position = u_matrix * vec4(a_pos, t > 0.0 ? height : base, 1); + float elevation = t > 0.0 ? height : base; + vec2 posInTile = a_pos + u_fill_translate; + + #ifdef GLOBE + vec3 spherePos = projectToSphere(posInTile); + vec3 elevatedPos = spherePos * (1.0 + elevation / GLOBE_RADIUS); + v_sphere_pos = elevatedPos; + gl_Position = interpolateProjectionFor3D(posInTile, spherePos, elevation); + #else + gl_Position = u_projection_matrix * vec4(posInTile, elevation, 1.0); + #endif // Relative luminance (how dark/bright is the surface color?) float colorvalue = color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722; @@ -57,7 +71,15 @@ void main() { color += ambientlight; // Calculate cos(theta), where theta is the angle between surface normal and diffuse light ray - float directional = clamp(dot(normal / 16384.0, u_lightpos), 0.0, 1.0); + vec3 normalForLighting = normal / 16384.0; + float directional = clamp(dot(normalForLighting, u_lightpos), 0.0, 1.0); + + #ifdef GLOBE + mat3 rotMatrix = globeGetRotationMatrix(spherePos); + normalForLighting = rotMatrix * normalForLighting; + // Interpolate dot product result instead of normals and light direction + directional = mix(directional, clamp(dot(normalForLighting, u_lightpos_globe), 0.0, 1.0), u_projection_transition); + #endif // Adjust directional so that // the range of values for highlight/shading is narrower diff --git a/src/shaders/fill_extrusion_pattern.fragment.glsl b/src/shaders/fill_extrusion_pattern.fragment.glsl index c498a367e3..74f3a95445 100644 --- a/src/shaders/fill_extrusion_pattern.fragment.glsl +++ b/src/shaders/fill_extrusion_pattern.fragment.glsl @@ -1,6 +1,11 @@ uniform vec2 u_texsize; uniform float u_fade; +#ifdef GLOBE + in vec3 v_sphere_pos; + uniform vec3 u_camera_pos_globe; +#endif + uniform sampler2D u_image; in vec2 v_pos_a; @@ -14,8 +19,6 @@ in vec4 v_lighting; #pragma mapbox: define lowp float pixel_ratio_from #pragma mapbox: define lowp float pixel_ratio_to - - void main() { #pragma mapbox: initialize lowp float base #pragma mapbox: initialize lowp float height @@ -41,7 +44,20 @@ void main() { fragColor = mixedColor * v_lighting; -#ifdef OVERDRAW_INSPECTOR - fragColor = vec4(1.0); -#endif + #ifdef OVERDRAW_INSPECTOR + fragColor = vec4(1.0); + #endif + + #ifdef GLOBE + // Discard fragments that are occluded by the planet + // See comment in fill_extrusion.fragment.glsl + vec3 toPlanetCenter = -v_sphere_pos; + vec3 toCameraNormalized = normalize(u_camera_pos_globe - v_sphere_pos); + float t = dot(toPlanetCenter, toCameraNormalized); + vec3 nearest = v_sphere_pos + toCameraNormalized * max(t, 0.0); + float distance_to_planet_center_squared = dot(nearest, nearest); + if (distance_to_planet_center_squared < u_projection_transition) { + discard; + } + #endif } diff --git a/src/shaders/fill_extrusion_pattern.vertex.glsl b/src/shaders/fill_extrusion_pattern.vertex.glsl index eecc343a17..30e485fde5 100644 --- a/src/shaders/fill_extrusion_pattern.vertex.glsl +++ b/src/shaders/fill_extrusion_pattern.vertex.glsl @@ -1,13 +1,14 @@ -uniform mat4 u_matrix; uniform vec2 u_pixel_coord_upper; uniform vec2 u_pixel_coord_lower; uniform float u_height_factor; uniform vec3 u_scale; uniform float u_vertical_gradient; uniform lowp float u_opacity; +uniform vec2 u_fill_translate; uniform vec3 u_lightcolor; uniform lowp vec3 u_lightpos; +uniform lowp vec3 u_lightpos_globe; uniform lowp float u_lightintensity; in vec2 a_pos; @@ -17,6 +18,10 @@ in vec4 a_normal_ed; in vec2 a_centroid; #endif +#ifdef GLOBE + out vec3 v_sphere_pos; +#endif + out vec2 v_pos_a; out vec2 v_pos_b; out vec4 v_lighting; @@ -52,11 +57,11 @@ void main() { vec2 display_size_b = (pattern_br_b - pattern_tl_b) / pixel_ratio_to; #ifdef TERRAIN3D - // Raise the "ceiling" of elements by the elevation of the centroid, in meters. + // Raise the "ceiling" of elements by the elevation of the centroid, in meters. float height_terrain3d_offset = get_elevation(a_centroid); - // To avoid having buildings "hang above a slope", create a "basement" - // by lowering the "floor" of ground-level (and below) elements. - // This is in addition to the elevation of the centroid, in meters. + // To avoid having buildings "hang above a slope", create a "basement" + // by lowering the "floor" of ground-level (and below) elements. + // This is in addition to the elevation of the centroid, in meters. float base_terrain3d_offset = height_terrain3d_offset - (base > 0.0 ? 0.0 : 10.0); #else float height_terrain3d_offset = 0.0; @@ -68,13 +73,21 @@ void main() { height = max(0.0, height) + height_terrain3d_offset; float t = mod(normal.x, 2.0); - float z = t > 0.0 ? height : base; - - gl_Position = u_matrix * vec4(a_pos, z, 1); + float elevation = t > 0.0 ? height : base; + vec2 posInTile = a_pos + u_fill_translate; + + #ifdef GLOBE + vec3 spherePos = projectToSphere(posInTile); + vec3 elevatedPos = spherePos * (1.0 + elevation / GLOBE_RADIUS); + v_sphere_pos = elevatedPos; + gl_Position = interpolateProjectionFor3D(posInTile, spherePos, elevation); + #else + gl_Position = u_projection_matrix * vec4(posInTile, elevation, 1.0); + #endif vec2 pos = normal.x == 1.0 && normal.y == 0.0 && normal.z == 16384.0 - ? a_pos // extrusion top - : vec2(edgedistance, z * u_height_factor); // extrusion side + ? a_pos // extrusion top - note the lack of u_fill_translate, because translation should not affect the pattern + : vec2(edgedistance, elevation * u_height_factor); // extrusion side v_pos_a = get_pattern_pos(u_pixel_coord_upper, u_pixel_coord_lower, fromScale * display_size_a, tileRatio, pos); v_pos_b = get_pattern_pos(u_pixel_coord_upper, u_pixel_coord_lower, toScale * display_size_b, tileRatio, pos); diff --git a/src/shaders/fill_outline.fragment.glsl b/src/shaders/fill_outline.fragment.glsl index f13b94edac..a3e6d8eb54 100644 --- a/src/shaders/fill_outline.fragment.glsl +++ b/src/shaders/fill_outline.fragment.glsl @@ -1,4 +1,7 @@ in vec2 v_pos; +#ifdef GLOBE +in float v_depth; +#endif #pragma mapbox: define highp vec4 outline_color #pragma mapbox: define lowp float opacity @@ -11,6 +14,15 @@ void main() { float alpha = 1.0 - smoothstep(0.0, 1.0, dist); fragColor = outline_color * (alpha * opacity); + #ifdef GLOBE + if (v_depth > 1.0) { + // Hides polygon outlines that are visible on the backfacing side of the globe. + // This is needed, because some hardware seems to apply glDepthRange first and then apply clipping, which is the wrong order. + // Other layers fix this by using backface culling, but that is unavailable for line primitives, so we clip the lines in software here. + discard; + } + #endif + #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); #endif diff --git a/src/shaders/fill_outline.vertex.glsl b/src/shaders/fill_outline.vertex.glsl index a4a654fe7c..ea38c928ea 100644 --- a/src/shaders/fill_outline.vertex.glsl +++ b/src/shaders/fill_outline.vertex.glsl @@ -1,9 +1,12 @@ -in vec2 a_pos; - -uniform mat4 u_matrix; uniform vec2 u_world; +uniform vec2 u_fill_translate; + +in vec2 a_pos; out vec2 v_pos; +#ifdef GLOBE +out float v_depth; +#endif #pragma mapbox: define highp vec4 outline_color #pragma mapbox: define lowp float opacity @@ -12,6 +15,10 @@ void main() { #pragma mapbox: initialize highp vec4 outline_color #pragma mapbox: initialize lowp float opacity - gl_Position = u_matrix * vec4(a_pos, 0, 1); + gl_Position = projectTile(a_pos + u_fill_translate); + v_pos = (gl_Position.xy / gl_Position.w + 1.0) / 2.0 * u_world; + #ifdef GLOBE + v_depth = gl_Position.z / gl_Position.w; + #endif } diff --git a/src/shaders/fill_outline_pattern.fragment.glsl b/src/shaders/fill_outline_pattern.fragment.glsl index 07984c7174..4d0d5a28b9 100644 --- a/src/shaders/fill_outline_pattern.fragment.glsl +++ b/src/shaders/fill_outline_pattern.fragment.glsl @@ -6,6 +6,9 @@ uniform float u_fade; in vec2 v_pos_a; in vec2 v_pos_b; in vec2 v_pos; +#ifdef GLOBE +in float v_depth; +#endif #pragma mapbox: define lowp float opacity #pragma mapbox: define lowp vec4 pattern_from @@ -34,9 +37,15 @@ void main() { float dist = length(v_pos - gl_FragCoord.xy); float alpha = 1.0 - smoothstep(0.0, 1.0, dist); - fragColor = mix(color1, color2, u_fade) * alpha * opacity; + #ifdef GLOBE + if (v_depth > 1.0) { + // See comment in fill_outline.fragment.glsl + discard; + } + #endif + #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); #endif diff --git a/src/shaders/fill_outline_pattern.vertex.glsl b/src/shaders/fill_outline_pattern.vertex.glsl index f19ddd6e11..c1b49bc27c 100644 --- a/src/shaders/fill_outline_pattern.vertex.glsl +++ b/src/shaders/fill_outline_pattern.vertex.glsl @@ -1,14 +1,17 @@ -uniform mat4 u_matrix; uniform vec2 u_world; uniform vec2 u_pixel_coord_upper; uniform vec2 u_pixel_coord_lower; uniform vec3 u_scale; +uniform vec2 u_fill_translate; in vec2 a_pos; out vec2 v_pos_a; out vec2 v_pos_b; out vec2 v_pos; +#ifdef GLOBE +out float v_depth; +#endif #pragma mapbox: define lowp float opacity #pragma mapbox: define lowp vec4 pattern_from @@ -32,7 +35,7 @@ void main() { float fromScale = u_scale.y; float toScale = u_scale.z; - gl_Position = u_matrix * vec4(a_pos, 0, 1); + gl_Position = projectTile(a_pos + u_fill_translate); vec2 display_size_a = (pattern_br_a - pattern_tl_a) / pixel_ratio_from; vec2 display_size_b = (pattern_br_b - pattern_tl_b) / pixel_ratio_to; @@ -41,4 +44,7 @@ void main() { v_pos_b = get_pattern_pos(u_pixel_coord_upper, u_pixel_coord_lower, toScale * display_size_b, tileRatio, a_pos); v_pos = (gl_Position.xy / gl_Position.w + 1.0) / 2.0 * u_world; + #ifdef GLOBE + v_depth = gl_Position.z / gl_Position.w; + #endif } diff --git a/src/shaders/fill_pattern.vertex.glsl b/src/shaders/fill_pattern.vertex.glsl index abdaafb247..37470b9ece 100644 --- a/src/shaders/fill_pattern.vertex.glsl +++ b/src/shaders/fill_pattern.vertex.glsl @@ -1,7 +1,7 @@ -uniform mat4 u_matrix; uniform vec2 u_pixel_coord_upper; uniform vec2 u_pixel_coord_lower; uniform vec3 u_scale; +uniform vec2 u_fill_translate; in vec2 a_pos; @@ -32,7 +32,8 @@ void main() { vec2 display_size_a = (pattern_br_a - pattern_tl_a) / pixel_ratio_from; vec2 display_size_b = (pattern_br_b - pattern_tl_b) / pixel_ratio_to; - gl_Position = u_matrix * vec4(a_pos, 0, 1); + + gl_Position = projectTile(a_pos + u_fill_translate); v_pos_a = get_pattern_pos(u_pixel_coord_upper, u_pixel_coord_lower, fromScale * display_size_a, tileZoomRatio, a_pos); v_pos_b = get_pattern_pos(u_pixel_coord_upper, u_pixel_coord_lower, toScale * display_size_b, tileZoomRatio, a_pos); diff --git a/src/shaders/heatmap.vertex.glsl b/src/shaders/heatmap.vertex.glsl index e527fec3e8..f8c3d2b502 100644 --- a/src/shaders/heatmap.vertex.glsl +++ b/src/shaders/heatmap.vertex.glsl @@ -1,8 +1,8 @@ -uniform mat4 u_matrix; uniform float u_extrude_scale; uniform float u_opacity; uniform float u_intensity; +uniform highp float u_globe_extrude_scale; in vec2 a_pos; @@ -24,7 +24,8 @@ void main(void) { #pragma mapbox: initialize mediump float radius // decode the extrusion vector that we snuck into the a_pos vector - vec2 unscaled_extrude = vec2(mod(a_pos, 2.0) * 2.0 - 1.0); + vec2 pos_raw = a_pos + 32768.0; + vec2 unscaled_extrude = vec2(mod(pos_raw, 8.0) / 7.0 * 2.0 - 1.0); // This 'extrude' comes in ranging from [-1, -1], to [1, 1]. We'll use // it to produce the vertices of a square mesh framing the point feature @@ -46,9 +47,16 @@ void main(void) { // mesh position vec2 extrude = v_extrude * radius * u_extrude_scale; - // multiply a_pos by 0.5, since we had it * 2 in order to sneak + // Divide a_pos by 8, since we had it * 8 in order to sneak // in extrusion data - vec4 pos = vec4(floor(a_pos * 0.5) + extrude, get_elevation(floor(a_pos * 0.5)), 1); - - gl_Position = u_matrix * pos; + vec2 circle_center = floor(pos_raw / 8.0); + +#ifdef GLOBE + vec2 angles = v_extrude * radius * u_globe_extrude_scale; + vec3 center_vector = projectToSphere(circle_center); + vec3 corner_vector = globeRotateVector(center_vector, angles); + gl_Position = interpolateProjection(circle_center + extrude, corner_vector, 0.0); +#else + gl_Position = projectTileFor3D(circle_center + extrude, get_elevation(circle_center)); +#endif } diff --git a/src/shaders/hillshade.vertex.glsl b/src/shaders/hillshade.vertex.glsl index 10108d96ba..e39f92cd83 100644 --- a/src/shaders/hillshade.vertex.glsl +++ b/src/shaders/hillshade.vertex.glsl @@ -1,11 +1,18 @@ uniform mat4 u_matrix; in vec2 a_pos; -in vec2 a_texture_pos; out vec2 v_pos; void main() { - gl_Position = u_matrix * vec4(a_pos, 0, 1); - v_pos = a_texture_pos / 8192.0; + gl_Position = projectTile(a_pos); + v_pos = a_pos / 8192.0; + // North pole + if (a_pos.y < -32767.5) { + v_pos.y = 0.0; + } + // South pole + if (a_pos.y > 32766.5) { + v_pos.y = 1.0; + } } diff --git a/src/shaders/line.fragment.glsl b/src/shaders/line.fragment.glsl index 4ea11c11a4..2bd82dc828 100644 --- a/src/shaders/line.fragment.glsl +++ b/src/shaders/line.fragment.glsl @@ -3,6 +3,9 @@ uniform lowp float u_device_pixel_ratio; in vec2 v_width2; in vec2 v_normal; in float v_gamma_scale; +#ifdef GLOBE +in float v_depth; +#endif #pragma mapbox: define highp vec4 color #pragma mapbox: define lowp float blur @@ -24,6 +27,17 @@ void main() { fragColor = color * (alpha * opacity); + #ifdef GLOBE + if (v_depth > 1.0) { + // Hides lines that are visible on the backfacing side of the globe. + // This is needed, because some hardware seems to apply glDepthRange first and then apply clipping, which is the wrong order. + // Other layers fix this by using backface culling, but the line layer's geometry (actually drawn as polygons) is complex and partly resolved in the shader, + // so we can't easily ensure that all triangles have the proper winding order in the vertex buffer creation step. + // Thus we render line geometry without face culling, and clip the lines manually here. + discard; + } + #endif + #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); #endif diff --git a/src/shaders/line.vertex.glsl b/src/shaders/line.vertex.glsl index 457c06686b..92a7b38779 100644 --- a/src/shaders/line.vertex.glsl +++ b/src/shaders/line.vertex.glsl @@ -9,7 +9,7 @@ in vec2 a_pos_normal; in vec4 a_data; -uniform mat4 u_matrix; +uniform vec2 u_translation; uniform mediump float u_ratio; uniform vec2 u_units_to_pixels; uniform lowp float u_device_pixel_ratio; @@ -18,6 +18,9 @@ out vec2 v_normal; out vec2 v_width2; out float v_gamma_scale; out highp float v_linesofar; +#ifdef GLOBE +out float v_depth; +#endif #pragma mapbox: define highp vec4 color #pragma mapbox: define lowp float blur @@ -73,15 +76,20 @@ void main() { mediump float t = 1.0 - abs(u); mediump vec2 offset2 = offset * a_extrude * scale * normal.y * mat2(t, -u, u, t); - vec4 projected_extrude = u_matrix * vec4(dist / u_ratio, 0.0, 0.0); - gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; + float adjustedThickness = projectLineThickness(pos.y); + vec4 projected_no_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation); + vec4 projected_with_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation + dist / u_ratio * adjustedThickness); + gl_Position = projected_with_extrude; + #ifdef GLOBE + v_depth = gl_Position.z / gl_Position.w; + #endif // calculate how much the perspective view squishes or stretches the extrude #ifdef TERRAIN3D v_gamma_scale = 1.0; // not needed, because this is done automatically via the mesh #else float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); + float extrude_length_with_perspective = length((projected_with_extrude.xy - projected_no_extrude.xy) / projected_with_extrude.w * u_units_to_pixels); v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; #endif diff --git a/src/shaders/line_gradient.fragment.glsl b/src/shaders/line_gradient.fragment.glsl index fb4221f48e..6c730726de 100644 --- a/src/shaders/line_gradient.fragment.glsl +++ b/src/shaders/line_gradient.fragment.glsl @@ -5,6 +5,9 @@ in vec2 v_width2; in vec2 v_normal; in float v_gamma_scale; in highp vec2 v_uv; +#ifdef GLOBE +in float v_depth; +#endif #pragma mapbox: define lowp float blur #pragma mapbox: define lowp float opacity @@ -28,6 +31,13 @@ void main() { fragColor = color * (alpha * opacity); + #ifdef GLOBE + if (v_depth > 1.0) { + // See comment in line.fragment.glsl + discard; + } + #endif + #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); #endif diff --git a/src/shaders/line_gradient.vertex.glsl b/src/shaders/line_gradient.vertex.glsl index 7872b2f36c..08a9ae92fc 100644 --- a/src/shaders/line_gradient.vertex.glsl +++ b/src/shaders/line_gradient.vertex.glsl @@ -11,7 +11,7 @@ in vec4 a_data; in float a_uv_x; in float a_split_index; -uniform mat4 u_matrix; +uniform vec2 u_translation; uniform mediump float u_ratio; uniform lowp float u_device_pixel_ratio; uniform vec2 u_units_to_pixels; @@ -21,6 +21,9 @@ out vec2 v_normal; out vec2 v_width2; out float v_gamma_scale; out highp vec2 v_uv; +#ifdef GLOBE +out float v_depth; +#endif #pragma mapbox: define lowp float blur #pragma mapbox: define lowp float opacity @@ -76,15 +79,20 @@ void main() { mediump float t = 1.0 - abs(u); mediump vec2 offset2 = offset * a_extrude * scale * normal.y * mat2(t, -u, u, t); - vec4 projected_extrude = u_matrix * vec4(dist / u_ratio, 0.0, 0.0); - gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; + float adjustedThickness = projectLineThickness(pos.y); + vec4 projected_no_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation); + vec4 projected_with_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation + dist / u_ratio * adjustedThickness); + gl_Position = projected_with_extrude; + #ifdef GLOBE + v_depth = gl_Position.z / gl_Position.w; + #endif // calculate how much the perspective view squishes or stretches the extrude #ifdef TERRAIN3D v_gamma_scale = 1.0; // not needed, because this is done automatically via the mesh #else float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); + float extrude_length_with_perspective = length((projected_with_extrude.xy - projected_no_extrude.xy) / projected_with_extrude.w * u_units_to_pixels); v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; #endif diff --git a/src/shaders/line_pattern.fragment.glsl b/src/shaders/line_pattern.fragment.glsl index 2ab4bd7023..a2288aa433 100644 --- a/src/shaders/line_pattern.fragment.glsl +++ b/src/shaders/line_pattern.fragment.glsl @@ -13,6 +13,9 @@ in vec2 v_width2; in float v_linesofar; in float v_gamma_scale; in float v_width; +#ifdef GLOBE +in float v_depth; +#endif #pragma mapbox: define lowp vec4 pattern_from #pragma mapbox: define lowp vec4 pattern_to @@ -71,6 +74,13 @@ void main() { fragColor = color * alpha * opacity; + #ifdef GLOBE + if (v_depth > 1.0) { + // See comment in line.fragment.glsl + discard; + } + #endif + #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); #endif diff --git a/src/shaders/line_pattern.vertex.glsl b/src/shaders/line_pattern.vertex.glsl index 70b668cfd0..4ecae36366 100644 --- a/src/shaders/line_pattern.vertex.glsl +++ b/src/shaders/line_pattern.vertex.glsl @@ -13,7 +13,7 @@ in vec2 a_pos_normal; in vec4 a_data; -uniform mat4 u_matrix; +uniform vec2 u_translation; uniform vec2 u_units_to_pixels; uniform mediump float u_ratio; uniform lowp float u_device_pixel_ratio; @@ -23,6 +23,9 @@ out vec2 v_width2; out float v_linesofar; out float v_gamma_scale; out float v_width; +#ifdef GLOBE +out float v_depth; +#endif #pragma mapbox: define lowp float blur #pragma mapbox: define lowp float opacity @@ -85,15 +88,20 @@ void main() { mediump float t = 1.0 - abs(u); mediump vec2 offset2 = offset * a_extrude * scale * normal.y * mat2(t, -u, u, t); - vec4 projected_extrude = u_matrix * vec4(dist / u_ratio, 0.0, 0.0); - gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; + float adjustedThickness = projectLineThickness(pos.y); + vec4 projected_no_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation); + vec4 projected_with_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation + dist / u_ratio * adjustedThickness); + gl_Position = projected_with_extrude; + #ifdef GLOBE + v_depth = gl_Position.z / gl_Position.w; + #endif // calculate how much the perspective view squishes or stretches the extrude #ifdef TERRAIN3D v_gamma_scale = 1.0; // not needed, because this is done automatically via the mesh #else float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); + float extrude_length_with_perspective = length((projected_with_extrude.xy - projected_no_extrude.xy) / projected_with_extrude.w * u_units_to_pixels); v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; #endif diff --git a/src/shaders/line_sdf.fragment.glsl b/src/shaders/line_sdf.fragment.glsl index 3cbd2c9b66..aa0ad05c01 100644 --- a/src/shaders/line_sdf.fragment.glsl +++ b/src/shaders/line_sdf.fragment.glsl @@ -9,6 +9,9 @@ in vec2 v_width2; in vec2 v_tex_a; in vec2 v_tex_b; in float v_gamma_scale; +#ifdef GLOBE +in float v_depth; +#endif #pragma mapbox: define highp vec4 color #pragma mapbox: define lowp float blur @@ -39,6 +42,13 @@ void main() { fragColor = color * (alpha * opacity); + #ifdef GLOBE + if (v_depth > 1.0) { + // See comment in line.fragment.glsl + discard; + } + #endif + #ifdef OVERDRAW_INSPECTOR fragColor = vec4(1.0); #endif diff --git a/src/shaders/line_sdf.vertex.glsl b/src/shaders/line_sdf.vertex.glsl index 3324b63c18..8c6c345b66 100644 --- a/src/shaders/line_sdf.vertex.glsl +++ b/src/shaders/line_sdf.vertex.glsl @@ -13,7 +13,7 @@ in vec2 a_pos_normal; in vec4 a_data; -uniform mat4 u_matrix; +uniform vec2 u_translation; uniform mediump float u_ratio; uniform lowp float u_device_pixel_ratio; uniform vec2 u_patternscale_a; @@ -27,6 +27,9 @@ out vec2 v_width2; out vec2 v_tex_a; out vec2 v_tex_b; out float v_gamma_scale; +#ifdef GLOBE +out float v_depth; +#endif #pragma mapbox: define highp vec4 color #pragma mapbox: define lowp float blur @@ -73,7 +76,7 @@ void main() { // Scale the extrusion vector down to a normal and then up by the line width // of this vertex. - mediump vec2 dist =outset * a_extrude * scale; + mediump vec2 dist = outset * a_extrude * scale; // Calculate the offset when drawing a line that is to the side of the actual line. // We do this by creating a vector that points towards the extrude, but rotate @@ -83,15 +86,20 @@ void main() { mediump float t = 1.0 - abs(u); mediump vec2 offset2 = offset * a_extrude * scale * normal.y * mat2(t, -u, u, t); - vec4 projected_extrude = u_matrix * vec4(dist / u_ratio, 0.0, 0.0); - gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; + float adjustedThickness = projectLineThickness(pos.y); + vec4 projected_no_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation); + vec4 projected_with_extrude = projectTile(pos + offset2 / u_ratio * adjustedThickness + u_translation + dist / u_ratio * adjustedThickness); + gl_Position = projected_with_extrude; + #ifdef GLOBE + v_depth = gl_Position.z / gl_Position.w; + #endif // calculate how much the perspective view squishes or stretches the extrude #ifdef TERRAIN3D v_gamma_scale = 1.0; // not needed, because this is done automatically via the mesh #else float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); + float extrude_length_with_perspective = length((projected_with_extrude.xy - projected_no_extrude.xy) / projected_with_extrude.w * u_units_to_pixels); v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; #endif 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 12711ad4e3..b110b43850 100644 --- a/src/shaders/shaders.ts +++ b/src/shaders/shaders.ts @@ -56,14 +56,29 @@ import symbolTextAndIconVert from './symbol_text_and_icon.vertex.glsl.g'; import terrainDepthFrag from './terrain_depth.fragment.glsl.g'; import terrainCoordsFrag from './terrain_coords.fragment.glsl.g'; import terrainFrag from './terrain.fragment.glsl.g'; -import terrainDepthVert from './terrain_depth.vertex.glsl.g'; -import terrainCoordsVert from './terrain_coords.vertex.glsl.g'; import terrainVert from './terrain.vertex.glsl.g'; +import terrainVertDepth from './terrain_depth.vertex.glsl.g'; +import terrainVertCoords from './terrain_coords.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'; +import atmosphereFrag from './atmosphere.fragment.glsl.g'; +import atmosphereVert from './atmosphere.vertex.glsl.g'; import skyFrag from './sky.fragment.glsl.g'; import skyVert from './sky.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), @@ -90,13 +105,16 @@ export const shaders = { symbolSDF: compile(symbolSDFFrag, symbolSDFVert), symbolTextAndIcon: compile(symbolTextAndIconFrag, symbolTextAndIconVert), terrain: compile(terrainFrag, terrainVert), - terrainDepth: compile(terrainDepthFrag, terrainDepthVert), - terrainCoords: compile(terrainCoordsFrag, terrainCoordsVert), - sky: compile(skyFrag, skyVert)}; + terrainDepth: compile(terrainDepthFrag, terrainVertDepth), + terrainCoords: compile(terrainCoordsFrag, terrainVertCoords), + projectionErrorMeasurement: compile(projectionErrorMeasurementFrag, projectionErrorMeasurementVert), + atmosphere: compile(atmosphereFrag, atmosphereVert), + sky: compile(skyFrag, skyVert) +}; // 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 d390a1dfdd..51bc16dd1c 100644 --- a/src/shaders/symbol_icon.vertex.glsl +++ b/src/shaders/symbol_icon.vertex.glsl @@ -13,7 +13,6 @@ uniform highp float u_pitch; uniform bool u_rotate_symbol; uniform highp float u_aspect_ratio; uniform float u_fade_change; -uniform mat4 u_matrix; uniform mat4 u_label_plane_matrix; uniform mat4 u_coord_matrix; uniform bool u_is_text; @@ -27,10 +26,6 @@ uniform float u_pitched_scale; out vec2 v_tex; out float v_fade_opacity; -vec4 projectTileWithElevation(vec2 posInTile, float elevation) { - return u_matrix * vec4(posInTile, elevation, 1.0); -} - #pragma mapbox: define lowp float opacity void main() { @@ -102,6 +97,12 @@ void main() { float z = float(u_pitch_with_map) * projected_pos.z / projected_pos.w; float projectionScaling = 1.0; +#ifdef GLOBE + if(u_pitch_with_map && !u_is_along_line) { + float anchor_pos_tile_y = (u_coord_matrix * vec4(projected_pos.xy / projected_pos.w, z, 1.0)).y; + projectionScaling = mix(projectionScaling, 1.0 / circumferenceRatioAtTileY(anchor_pos_tile_y) * u_pitched_scale, u_projection_transition); + } +#endif vec4 finalPos = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * max(a_minFontScale, fontScale) + a_pxoffset / 16.0) * projectionScaling, z, 1.0); if(u_pitch_with_map) { diff --git a/src/shaders/symbol_sdf.vertex.glsl b/src/shaders/symbol_sdf.vertex.glsl index e5ce991675..f72fe62f8f 100644 --- a/src/shaders/symbol_sdf.vertex.glsl +++ b/src/shaders/symbol_sdf.vertex.glsl @@ -15,7 +15,6 @@ uniform bool u_is_size_zoom_constant; uniform bool u_is_size_feature_constant; uniform highp float u_size_t; // used to interpolate between zoom stops when size is a composite function uniform highp float u_size; // used when size is both zoom and feature constant -uniform mat4 u_matrix; uniform mat4 u_label_plane_matrix; uniform mat4 u_coord_matrix; uniform bool u_is_text; @@ -34,10 +33,6 @@ uniform float u_pitched_scale; out vec2 v_data0; out vec3 v_data1; -vec4 projectTileWithElevation(vec2 posInTile, float elevation) { - return u_matrix * vec4(posInTile, elevation, 1.0); -} - #pragma mapbox: define highp vec4 fill_color #pragma mapbox: define highp vec4 halo_color #pragma mapbox: define lowp float opacity @@ -124,6 +119,13 @@ void main() { float z = float(u_pitch_with_map) * projected_pos.z / projected_pos.w; float projectionScaling = 1.0; +#ifdef GLOBE + if(u_pitch_with_map && !u_is_along_line) { + // Lines would behave in very weird ways if this adjustment was used for them. + float anchor_pos_tile_y = (u_coord_matrix * vec4(projected_pos.xy / projected_pos.w, z, 1.0)).y; + projectionScaling = mix(projectionScaling, 1.0 / circumferenceRatioAtTileY(anchor_pos_tile_y) * u_pitched_scale, u_projection_transition); + } +#endif vec4 finalPos = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * fontScale + a_pxoffset) * projectionScaling, z, 1.0); if(u_pitch_with_map) { diff --git a/src/shaders/symbol_text_and_icon.vertex.glsl b/src/shaders/symbol_text_and_icon.vertex.glsl index f78d6fe7fb..eccaab37ef 100644 --- a/src/shaders/symbol_text_and_icon.vertex.glsl +++ b/src/shaders/symbol_text_and_icon.vertex.glsl @@ -14,7 +14,6 @@ uniform bool u_is_size_zoom_constant; uniform bool u_is_size_feature_constant; uniform highp float u_size_t; // used to interpolate between zoom stops when size is a composite function uniform highp float u_size; // used when size is both zoom and feature constant -uniform mat4 u_matrix; uniform mat4 u_label_plane_matrix; uniform mat4 u_coord_matrix; uniform bool u_is_text; @@ -34,10 +33,6 @@ uniform float u_pitched_scale; out vec4 v_data0; out vec4 v_data1; -vec4 projectTileWithElevation(vec2 posInTile, float elevation) { - return u_matrix * vec4(posInTile, elevation, 1.0); -} - #pragma mapbox: define highp vec4 fill_color #pragma mapbox: define highp vec4 halo_color #pragma mapbox: define lowp float opacity @@ -121,6 +116,12 @@ void main() { float z = float(u_pitch_with_map) * projected_pos.z / projected_pos.w; float projectionScaling = 1.0; +#ifdef GLOBE + if(u_pitch_with_map && !u_is_along_line) { + float anchor_pos_tile_y = (u_coord_matrix * vec4(projected_pos.xy / projected_pos.w, z, 1.0)).y; + projectionScaling = mix(projectionScaling, 1.0 / circumferenceRatioAtTileY(anchor_pos_tile_y) * u_pitched_scale, u_projection_transition); + } +#endif vec4 finalPos = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * fontScale) * projectionScaling, z, 1.0); if(u_pitch_with_map) { diff --git a/src/shaders/terrain.fragment.glsl b/src/shaders/terrain.fragment.glsl index 56b1fcb8f6..2092137585 100644 --- a/src/shaders/terrain.fragment.glsl +++ b/src/shaders/terrain.fragment.glsl @@ -19,7 +19,7 @@ vec4 linearToGamma(vec4 color) { } void main() { - vec4 surface_color = texture2D(u_texture, v_texture_pos); + vec4 surface_color = texture(u_texture, vec2(v_texture_pos.x, 1.0 - v_texture_pos.y)); if (v_fog_depth > u_fog_ground_blend) { vec4 surface_color_linear = gammaToLinear(surface_color); float blend_color = smoothstep(0.0, 1.0, max((v_fog_depth - u_horizon_fog_blend) / (1.0 - u_horizon_fog_blend), 0.0)); diff --git a/src/shaders/terrain.vertex.glsl b/src/shaders/terrain.vertex.glsl index 2a80424204..4f32cc2c1e 100644 --- a/src/shaders/terrain.vertex.glsl +++ b/src/shaders/terrain.vertex.glsl @@ -1,6 +1,5 @@ in vec3 a_pos3d; -uniform mat4 u_matrix; uniform mat4 u_fog_matrix; uniform float u_ele_delta; @@ -11,7 +10,7 @@ void main() { float ele = get_elevation(a_pos3d.xy); float ele_delta = a_pos3d.z == 1.0 ? u_ele_delta : 0.0; v_texture_pos = a_pos3d.xy / 8192.0; - gl_Position = u_matrix * vec4(a_pos3d.xy, ele - ele_delta, 1.0); + gl_Position = projectTileFor3D(a_pos3d.xy, get_elevation(a_pos3d.xy) - ele_delta); vec4 pos = u_fog_matrix * vec4(a_pos3d.xy, ele, 1.0); v_fog_depth = pos.z / pos.w * 0.5 + 0.5; } \ No newline at end of file diff --git a/src/shaders/terrain_coords.vertex.glsl b/src/shaders/terrain_coords.vertex.glsl index 08d29ba981..7400b884ea 100644 --- a/src/shaders/terrain_coords.vertex.glsl +++ b/src/shaders/terrain_coords.vertex.glsl @@ -1,6 +1,5 @@ attribute vec3 a_pos3d; -uniform mat4 u_matrix; uniform float u_ele_delta; out vec2 v_texture_pos; @@ -9,5 +8,5 @@ void main() { float ele = get_elevation(a_pos3d.xy); float ele_delta = a_pos3d.z == 1.0 ? u_ele_delta : 0.0; v_texture_pos = a_pos3d.xy / 8192.0; - gl_Position = u_matrix * vec4(a_pos3d.xy, ele - ele_delta, 1.0); + gl_Position = projectTileFor3D(a_pos3d.xy, ele - ele_delta); } \ No newline at end of file diff --git a/src/shaders/terrain_depth.vertex.glsl b/src/shaders/terrain_depth.vertex.glsl index 9c080b4c22..a95e2b5ead 100644 --- a/src/shaders/terrain_depth.vertex.glsl +++ b/src/shaders/terrain_depth.vertex.glsl @@ -1,6 +1,5 @@ attribute vec3 a_pos3d; -uniform mat4 u_matrix; uniform float u_ele_delta; out float v_depth; @@ -8,6 +7,6 @@ out float v_depth; void main() { float ele = get_elevation(a_pos3d.xy); float ele_delta = a_pos3d.z == 1.0 ? u_ele_delta : 0.0; - gl_Position = u_matrix * vec4(a_pos3d.xy, ele - ele_delta, 1.0); + gl_Position = projectTileFor3D(a_pos3d.xy, ele - ele_delta); v_depth = gl_Position.z / gl_Position.w; } \ No newline at end of file diff --git a/src/source/canvas_source.test.ts b/src/source/canvas_source.test.ts index fd75dd95a3..5b52861920 100644 --- a/src/source/canvas_source.test.ts +++ b/src/source/canvas_source.test.ts @@ -1,13 +1,12 @@ import {CanvasSource} from '../source/canvas_source'; -import {Transform} from '../geo/transform'; +import {IReadonlyTransform} from '../geo/transform_interface'; import {Event, Evented} from '../util/evented'; 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'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; function createSource(options?) { const c = options && options.canvas || window.document.createElement('canvas'); @@ -27,13 +26,13 @@ function createSource(options?) { } class StubMap extends Evented { - transform: Transform; + transform: IReadonlyTransform; style: any; painter: any; constructor() { super(); - this.transform = new Transform(); + this.transform = new MercatorTransform(); this.style = {}; this.painter = { context: { @@ -190,8 +189,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/geojson_source.test.ts b/src/source/geojson_source.test.ts index 497b9ef041..d205f92065 100644 --- a/src/source/geojson_source.test.ts +++ b/src/source/geojson_source.test.ts @@ -1,13 +1,15 @@ import {Tile} from './tile'; import {OverscaledTileID} from './tile_id'; import {GeoJSONSource, GeoJSONSourceOptions} from './geojson_source'; -import {Transform} from '../geo/transform'; +import {IReadonlyTransform} from '../geo/transform_interface'; import {LngLat} from '../geo/lng_lat'; import {extend} from '../util/util'; import {Dispatcher} from '../util/dispatcher'; import {RequestManager} from '../util/request_manager'; +import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; import {ActorMessage, MessageType} from '../util/actor_messages'; import {Actor} from '../util/actor'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; const wrapDispatcher = (dispatcher) => { return { @@ -205,11 +207,11 @@ describe('GeoJSONSource#onRemove', () => { }); describe('GeoJSONSource#update', () => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(200, 200); const lngLat = LngLat.convert([-122.486052, 37.830348]); - const point = transform.locationPoint(lngLat); - transform.zoom = 15; + const point = transform.locationToScreenPoint(lngLat); + transform.setZoom(15); transform.setLocationAtPoint(lngLat, point); test('sends initial loadData request to dispatcher', () => new Promise(done => { @@ -407,8 +409,15 @@ describe('GeoJSONSource#update', () => { const source = new GeoJSONSource('id', {data: {}} as GeoJSONSourceOptions, mockDispatcher, undefined); source.map = { - transform: {} as Transform, - getPixelRatio() { return 1; } + transform: {} as IReadonlyTransform, + getPixelRatio() { return 1; }, + style: { + projection: { + get subdivisionGranularity() { + return SubdivisionGranularitySetting.noSubdivision; + } + } + } } as any; source.on('data', (e) => { diff --git a/src/source/geojson_source.ts b/src/source/geojson_source.ts index fabde168d9..33966157e8 100644 --- a/src/source/geojson_source.ts +++ b/src/source/geojson_source.ts @@ -13,6 +13,7 @@ import type {Actor} from '../util/actor'; import type {GeoJSONSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {GeoJSONSourceDiff} from './geojson_source_diff'; import type {GeoJSONWorkerOptions, LoadGeoJSONParameters} from './geojson_worker_source'; +import {WorkerTileParameters} from './worker_source'; import {MessageType} from '../util/actor_messages'; /** @@ -377,7 +378,7 @@ export class GeoJSONSource extends Evented implements Source { async loadTile(tile: Tile): Promise { const message = !tile.actor ? MessageType.loadTile : MessageType.reloadTile; tile.actor = this.actor; - const params = { + const params: WorkerTileParameters = { type: this.type, uid: tile.uid, tileID: tile.tileID, @@ -387,7 +388,8 @@ export class GeoJSONSource extends Evented implements Source { source: this.id, pixelRatio: this.map.getPixelRatio(), showCollisionBoxes: this.map.showCollisionBoxes, - promoteId: this.promoteId + promoteId: this.promoteId, + subdivisionGranularity: this.map.style.projection.subdivisionGranularity }; tile.abortController = new AbortController(); diff --git a/src/source/image_source.test.ts b/src/source/image_source.test.ts index 9b3358eaec..89055418e5 100644 --- a/src/source/image_source.test.ts +++ b/src/source/image_source.test.ts @@ -1,16 +1,15 @@ import {ImageSource} from './image_source'; import {Evented} from '../util/evented'; -import {Transform} from '../geo/transform'; +import {IReadonlyTransform} from '../geo/transform_interface'; import {extend} from '../util/util'; import {type FakeServer, fakeServer} from 'nise'; 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'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; function createSource(options) { options = extend({ @@ -22,13 +21,13 @@ function createSource(options) { } class StubMap extends Evented { - transform: Transform; + transform: IReadonlyTransform; painter: any; _requestManager: RequestManager; constructor() { super(); - this.transform = new Transform(); + this.transform = new MercatorTransform(); this._requestManager = { transformRequest: (url) => { return {url}; @@ -166,8 +165,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 a3453a0221..3675563f6e 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,8 @@ export class ImageSource extends Evented implements Source { texture: Texture | null; image: HTMLImageElement | ImageBitmap; tileID: CanonicalTileID; - _boundsArray: RasterBoundsArray; - boundsBuffer: VertexBuffer; - boundsSegments: SegmentVector; + tileCoords: Array; + flippedWindingOrder: boolean = false; _loaded: boolean; _request: AbortController; @@ -225,18 +220,8 @@ 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.flippedWindingOrder = hasWrongWindingOrder(this.tileCoords); this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); return this; @@ -250,14 +235,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); @@ -336,3 +313,14 @@ export function getCoordinatesCenterTileID(coords: Array) { Math.floor((minX + maxX) / 2 * tilesAtZoom), Math.floor((minY + maxY) / 2 * tilesAtZoom)); } + +function hasWrongWindingOrder(coords: Array) { + const e0x = coords[1].x - coords[0].x; + const e0y = coords[1].y - coords[0].y; + const e1x = coords[2].x - coords[0].x; + const e1y = coords[2].y - coords[0].y; + + const crossProduct = e0x * e1y - e0y * e1x; + + return crossProduct < 0; +} diff --git a/src/source/query_features.test.ts b/src/source/query_features.test.ts index 29fa78e44f..08897a59b2 100644 --- a/src/source/query_features.test.ts +++ b/src/source/query_features.test.ts @@ -3,13 +3,13 @@ import { querySourceFeatures } from './query_features'; import {SourceCache} from './source_cache'; -import {Transform} from '../geo/transform'; import Point from '@mapbox/point-geometry'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; describe('QueryFeatures#rendered', () => { test('returns empty object if source returns no tiles', () => { const mockSourceCache = {tilesIn () { return []; }} as any as SourceCache; - const transform = new Transform(); + const transform = new MercatorTransform(); const result = queryRenderedFeatures(mockSourceCache, {}, undefined, [] as Point[], undefined, transform); expect(result).toEqual({}); }); diff --git a/src/source/query_features.ts b/src/source/query_features.ts index a233c0a138..ad82bf8554 100644 --- a/src/source/query_features.ts +++ b/src/source/query_features.ts @@ -1,7 +1,7 @@ import type {SourceCache} from './source_cache'; import type {StyleLayer} from '../style/style_layer'; import type {CollisionIndex} from '../symbol/collision_index'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {RetainedQueryData} from '../symbol/placement'; import type {FilterSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {MapGeoJSONFeature} from '../util/vectortile_to_geojson'; @@ -58,7 +58,11 @@ function getPixelPosMatrix(transform, tileID) { const t = mat4.create(); mat4.translate(t, t, [1, 1, 0]); mat4.scale(t, t, [transform.width * 0.5, transform.height * 0.5, 1]); - return mat4.multiply(t, t, transform.calculatePosMatrix(tileID.toUnwrapped())); + if (transform.calculatePosMatrix) { // Globe: TODO: remove this hack once queryRendererFeatures supports globe properly + return mat4.multiply(t, t, transform.calculatePosMatrix(tileID.toUnwrapped())); + } else { + return t; + } } function queryIncludes3DLayer(layers: Array, styleLayers: {[_: string]: StyleLayer}, sourceID: string) { @@ -86,7 +90,7 @@ export function queryRenderedFeatures( serializedLayers: {[_: string]: any}, queryGeometry: Array, params: QueryRenderedFeaturesOptions, - transform: Transform + transform: IReadonlyTransform ): { [key: string]: Array<{featureIndex: number; feature: MapGeoJSONFeature}> } { const has3DLayer = queryIncludes3DLayer(params && params.layers, styleLayers, sourceCache.id); diff --git a/src/source/raster_tile_source.ts b/src/source/raster_tile_source.ts index 7ad46cb0bd..e29157e05a 100644 --- a/src/source/raster_tile_source.ts +++ b/src/source/raster_tile_source.ts @@ -6,7 +6,7 @@ import {ResourceType} from '../util/request_manager'; import {Event, ErrorEvent, Evented} from '../util/evented'; import {loadTileJson} from './load_tilejson'; import {TileBounds} from './tile_bounds'; -import {Texture} from '../render/texture'; +import {Texture, TextureFormat} from '../render/texture'; import type {Source} from './source'; import type {OverscaledTileID} from './tile_id'; @@ -66,6 +66,7 @@ export class RasterTileSource extends Evented implements Source { _loaded: boolean; _options: RasterSourceSpecification | RasterDEMSourceSpecification; _tileJSONRequest: AbortController; + _textureFormat: TextureFormat; constructor(id: string, options: RasterSourceSpecification | RasterDEMSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) { super(); @@ -80,11 +81,20 @@ export class RasterTileSource extends Evented implements Source { this.scheme = 'xyz'; this.tileSize = 512; this._loaded = false; + this._textureFormat = WebGLRenderingContext.RGBA; this._options = extend({type: 'raster'}, options); extend(this, pick(options, ['url', 'scheme', 'tileSize'])); } + set textureFormat(format: TextureFormat) { + this._textureFormat = format; + } + + get textureFormat(): TextureFormat { + return this._textureFormat; + } + async load() { this._loaded = false; this.fire(new Event('dataloading', {dataType: 'source'})); @@ -189,10 +199,12 @@ export class RasterTileSource extends Evented implements Source { const gl = context.gl; const img = response.data; tile.texture = this.map.painter.getTileTexture(img.width); + if (tile.texture) { - tile.texture.update(img, {useMipmap: true}); + // We need to update the format since tile textures are being reused by various sources + tile.texture.update(img, {useMipmap: true, format: this.textureFormat}); } else { - tile.texture = new Texture(context, img, gl.RGBA, {useMipmap: true}); + tile.texture = new Texture(context, img, this.textureFormat, {useMipmap: true}); tile.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE, gl.LINEAR_MIPMAP_NEAREST); } tile.state = 'loaded'; diff --git a/src/source/rtl_text_plugin_main_thread.test.ts b/src/source/rtl_text_plugin_main_thread.test.ts index 87704b6ddc..22e4fb62cd 100644 --- a/src/source/rtl_text_plugin_main_thread.test.ts +++ b/src/source/rtl_text_plugin_main_thread.test.ts @@ -23,7 +23,6 @@ describe('RTLMainThreadPlugin', () => { }); function broadcastMockSuccess(message: MessageType, payload: PluginState): Promise { - console.log('broadcastMockSuccessDefer', payload.pluginStatus); if (message === SyncRTLPluginStateMessageName) { if (payload.pluginStatus === 'loading') { const resultState: PluginState = { diff --git a/src/source/source_cache.test.ts b/src/source/source_cache.test.ts index a80f7a1e18..02ba196d8d 100644 --- a/src/source/source_cache.test.ts +++ b/src/source/source_cache.test.ts @@ -3,7 +3,6 @@ import {Map} from '../ui/map'; import {Source, addSourceType} from './source'; import {Tile} from './tile'; import {OverscaledTileID} from './tile_id'; -import {Transform} from '../geo/transform'; import {LngLat} from '../geo/lng_lat'; import Point from '@mapbox/point-geometry'; import {Event, ErrorEvent, Evented} from '../util/evented'; @@ -13,6 +12,7 @@ import {Dispatcher} from '../util/dispatcher'; import {TileBounds} from './tile_bounds'; import {sleep} from '../util/test/util'; import {TileCache} from './tile_cache'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; class SourceMock extends Evented implements Source { id: string; @@ -154,9 +154,8 @@ describe('SourceCache#addTile', () => { }; sourceCache.on('dataloading', () => { add++; }); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); sourceCache._addTile(tileID); sourceCache._removeTile(tileID.key); @@ -175,9 +174,8 @@ describe('SourceCache#addTile', () => { tile.state = 'loaded'; }; - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const tile = sourceCache._addTile(tileID); @@ -205,9 +203,8 @@ describe('SourceCache#addTile', () => { sourceCache._setTileReloadTimer(tileID.key, tile); }; - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const id = tileID.key; @@ -297,9 +294,8 @@ describe('SourceCache#removeTile', () => { }; sourceCache._source.unloadTile = jest.fn(); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); sourceCache._addTile(tileID); @@ -435,9 +431,9 @@ describe('SourceCache / Source lifecycle', () => { })); test('loaded() true after tile error', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 0; + transform.setZoom(0); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async () => { throw new Error('Error loading tile'); @@ -482,9 +478,9 @@ describe('SourceCache / Source lifecycle', () => { })); test('reloads tiles after a data event where source is updated', () => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 0; + transform.setZoom(0); const expected = [new OverscaledTileID(0, 0, 0, 0, 0).key, new OverscaledTileID(0, 0, 0, 0, 0).key]; expect.assertions(expected.length); @@ -506,9 +502,9 @@ describe('SourceCache / Source lifecycle', () => { }); test('does not reload errored tiles', () => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 1; + transform.setZoom(1); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -535,9 +531,9 @@ describe('SourceCache / Source lifecycle', () => { describe('SourceCache#update', () => { test('loads no tiles if used is false', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(512, 512); - transform.zoom = 0; + transform.setZoom(0); const sourceCache = createSourceCache({}, false); sourceCache.on('data', (e) => { @@ -551,9 +547,9 @@ describe('SourceCache#update', () => { })); test('loads covering tiles', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 0; + transform.setZoom(0); const sourceCache = createSourceCache({}); sourceCache.on('data', (e) => { @@ -567,9 +563,9 @@ describe('SourceCache#update', () => { })); test('respects Source#hasTile method if it is present', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 1; + transform.setZoom(1); const sourceCache = createSourceCache({ hasTile: (coord) => (coord.canonical.x !== 0) @@ -588,9 +584,9 @@ describe('SourceCache#update', () => { })); test('removes unused tiles', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 0; + transform.setZoom(0); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -602,7 +598,7 @@ describe('SourceCache#update', () => { sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([new OverscaledTileID(0, 0, 0, 0, 0).key]); - transform.zoom = 1; + transform.setZoom(1); sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([ @@ -619,10 +615,10 @@ describe('SourceCache#update', () => { })); test('retains parent tiles for pending children', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); (transform as any)._test = 'retains'; transform.resize(511, 511); - transform.zoom = 0; + transform.setZoom(0); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -634,7 +630,7 @@ describe('SourceCache#update', () => { sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([new OverscaledTileID(0, 0, 0, 0, 0).key]); - transform.zoom = 1; + transform.setZoom(1); sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([ @@ -651,10 +647,10 @@ describe('SourceCache#update', () => { })); test('retains parent tiles for pending children (wrapped)', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 0; - transform.center = new LngLat(360, 0); + transform.setZoom(0); + transform.setCenter(new LngLat(360, 0)); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -666,7 +662,7 @@ describe('SourceCache#update', () => { sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([new OverscaledTileID(0, 1, 0, 0, 0).key]); - transform.zoom = 1; + transform.setZoom(1); sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([ @@ -683,9 +679,9 @@ describe('SourceCache#update', () => { })); test('retains covered child tiles while parent tile is fading in', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 2; + transform.setZoom(2); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -706,7 +702,7 @@ describe('SourceCache#update', () => { new OverscaledTileID(2, 0, 2, 1, 1).key ]); - transform.zoom = 0; + transform.setZoom(0); sourceCache.update(transform); expect(sourceCache.getRenderableIds()).toHaveLength(5); @@ -717,9 +713,9 @@ describe('SourceCache#update', () => { })); test('retains a parent tile for fading even if a tile is partially covered by children', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 0; + transform.setZoom(0); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -734,10 +730,10 @@ describe('SourceCache#update', () => { if (e.sourceDataType === 'metadata') { sourceCache.update(transform); - transform.zoom = 2; + transform.setZoom(2); sourceCache.update(transform); - transform.zoom = 1; + transform.setZoom(1); sourceCache.update(transform); expect(sourceCache._coveredTiles[(new OverscaledTileID(0, 0, 0, 0, 0).key)]).toBe(true); @@ -748,9 +744,9 @@ describe('SourceCache#update', () => { })); test('retain children for fading fadeEndTime is 0 (added but registerFadeDuration() is not called yet)', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 1; + transform.setZoom(1); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -765,7 +761,7 @@ describe('SourceCache#update', () => { if (e.sourceDataType === 'metadata') { sourceCache.update(transform); - transform.zoom = 0; + transform.setZoom(0); sourceCache.update(transform); expect(sourceCache.getRenderableIds()).toHaveLength(5); @@ -776,9 +772,9 @@ describe('SourceCache#update', () => { })); test('retains children when tile.fadeEndTime is in the future', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 1; + transform.setZoom(1); const fadeTime = 100; @@ -800,7 +796,7 @@ describe('SourceCache#update', () => { // load children sourceCache.update(transform); - transform.zoom = 0; + transform.setZoom(0); sourceCache.update(transform); expect(sourceCache.getRenderableIds()).toHaveLength(5); @@ -820,12 +816,12 @@ describe('SourceCache#update', () => { })); test('retains overscaled loaded children', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 16; + transform.setZoom(16); // use slightly offset center so that sort order is better defined - transform.center = new LngLat(-0.001, 0.001); + transform.setCenter(new LngLat(-0.001, 0.001)); const sourceCache = createSourceCache({reparseOverscaled: true}); sourceCache._source.loadTile = async (tile) => { @@ -842,7 +838,7 @@ describe('SourceCache#update', () => { new OverscaledTileID(16, 0, 14, 8191, 8191).key ]); - transform.zoom = 15; + transform.setZoom(15); sourceCache.update(transform); expect(sourceCache.getRenderableIds()).toEqual([ @@ -859,20 +855,20 @@ describe('SourceCache#update', () => { test('reassigns tiles for large jumps in longitude', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 0; + transform.setZoom(0); const sourceCache = createSourceCache({}); sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { - transform.center = new LngLat(360, 0); + transform.setCenter(new LngLat(360, 0)); const tileID = new OverscaledTileID(0, 1, 0, 0, 0); sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([tileID.key]); const tile = sourceCache.getTile(tileID); - transform.center = new LngLat(0, 0); + transform.setCenter(new LngLat(0, 0)); const wrappedTileID = new OverscaledTileID(0, 0, 0, 0, 0); sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([wrappedTileID.key]); @@ -1371,10 +1367,8 @@ describe('SourceCache#clearTiles', () => { describe('SourceCache#tilesIn', () => { test('graceful response before source loaded', () => { - const tr = new Transform(); - tr.width = 512; - tr.height = 512; - tr._calcMatrices(); + const tr = new MercatorTransform(); + tr.resize(512, 512); const sourceCache = createSourceCache({noLoad: true}); sourceCache.transform = tr; sourceCache.onAdd(undefined); @@ -1392,10 +1386,10 @@ describe('SourceCache#tilesIn', () => { } test('regular tiles', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(512, 512); - transform.zoom = 1; - transform.center = new LngLat(0, 1); + transform.setZoom(1); + transform.setCenter(new LngLat(0, 1)); const sourceCache = createSourceCache(); sourceCache._source.loadTile = async (tile) => { @@ -1413,7 +1407,6 @@ describe('SourceCache#tilesIn', () => { new OverscaledTileID(1, 0, 1, 0, 0).key ]); - transform._calcMatrices(); const tiles = sourceCache.tilesIn([ new Point(0, 0), new Point(512, 256) @@ -1451,10 +1444,10 @@ describe('SourceCache#tilesIn', () => { sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(1024, 1024); - transform.zoom = 2.0; - transform.center = new LngLat(0, 1); + transform.setZoom(2); + transform.setCenter(new LngLat(0, 1)); sourceCache.update(transform); expect(sourceCache.getIds()).toEqual([ @@ -1500,9 +1493,9 @@ describe('SourceCache#tilesIn', () => { sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(512, 512); - transform.zoom = 2.0; + transform.setZoom(2.0); sourceCache.update(transform); done(); @@ -1521,7 +1514,7 @@ describe('source cache loaded', () => { sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { - const tr = new Transform(); + const tr = new MercatorTransform(); tr.resize(512, 512); sourceCache.update(tr); @@ -1544,7 +1537,7 @@ describe('source cache loaded', () => { sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { - const tr = new Transform(); + const tr = new MercatorTransform(); tr.resize(512, 512); sourceCache.update(tr); @@ -1622,8 +1615,8 @@ describe('source cache loaded', () => { return !this.tileBounds || this.tileBounds.contains(tileID.canonical); }; - const tr = new Transform(); - tr.zoom = 10; + const tr = new MercatorTransform(); + tr.setZoom(10); tr.resize(512, 512); const expectedTilesLoaded = 4; let loaded = 0; @@ -1680,8 +1673,8 @@ describe('source cache loaded', () => { }); sourceCache.onAdd(undefined); - const tr = new Transform(); - tr.zoom = 10; + const tr = new MercatorTransform(); + tr.setZoom(10); tr.resize(512, 512); sourceCache.update(tr); })); @@ -1697,7 +1690,7 @@ describe('source cache get ids', () => { ]; const sourceCache = createSourceCache({}); - sourceCache.transform = new Transform(); + sourceCache.transform = new MercatorTransform(); for (let i = 0; i < ids.length; i++) { sourceCache._tiles[ids[i].key] = {tileID: ids[i]} as any as Tile; } @@ -1715,9 +1708,8 @@ describe('SourceCache#findLoadedParent', () => { test('adds from previously used tiles (sourceCache._tiles)', () => { const sourceCache = createSourceCache({}); sourceCache.onAdd(undefined); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const tile = { @@ -1734,9 +1726,8 @@ describe('SourceCache#findLoadedParent', () => { test('retains parents', () => { const sourceCache = createSourceCache({}); sourceCache.onAdd(undefined); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const tile = new Tile(new OverscaledTileID(1, 0, 1, 0, 0), 512); @@ -1751,9 +1742,8 @@ describe('SourceCache#findLoadedParent', () => { test('Search cache for loaded parent tiles', () => { const sourceCache = createSourceCache({}); sourceCache.onAdd(undefined); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const mockTile = id => { @@ -1818,9 +1808,8 @@ describe('SourceCache#findLoadedSibling', () => { test('adds from previously used tiles (sourceCache._tiles)', () => { const sourceCache = createSourceCache({}); sourceCache.onAdd(undefined); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const tile = { @@ -1837,9 +1826,8 @@ describe('SourceCache#findLoadedSibling', () => { test('retains siblings', () => { const sourceCache = createSourceCache({}); sourceCache.onAdd(undefined); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const tile = new Tile(new OverscaledTileID(1, 0, 1, 0, 0), 512); @@ -1853,9 +1841,8 @@ describe('SourceCache#findLoadedSibling', () => { test('Search cache for loaded sibling tiles', () => { const sourceCache = createSourceCache({}); sourceCache.onAdd(undefined); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); const mockTile = id => { @@ -1938,9 +1925,8 @@ describe('SourceCache sets max cache size correctly', () => { tileSize: 256 }); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); // Expect max size to be ((512 / tileSize + 1) ^ 2) * 5 => 3 * 3 * 5 @@ -1952,9 +1938,8 @@ describe('SourceCache sets max cache size correctly', () => { tileSize: 512 }); - const tr = new Transform(); - tr.width = 512; - tr.height = 512; + const tr = new MercatorTransform(); + tr.resize(512, 512); sourceCache.updateCacheSize(tr); // Expect max size to be ((512 / tileSize + 1) ^ 2) * 5 => 2 * 2 * 5 @@ -1987,9 +1972,9 @@ describe('SourceCache#onRemove', () => { describe('SourceCache#usedForTerrain', () => { test('loads covering tiles with usedForTerrain with source zoom 0-14', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 10; + transform.setZoom(10); const sourceCache = createSourceCache({}); sourceCache.usedForTerrain = true; @@ -2008,9 +1993,9 @@ describe('SourceCache#usedForTerrain', () => { })); test('loads covering tiles with usedForTerrain with source zoom 8-14', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 10; + transform.setZoom(10); const sourceCache = createSourceCache({minzoom: 8, maxzoom: 14}); sourceCache.usedForTerrain = true; @@ -2028,9 +2013,9 @@ describe('SourceCache#usedForTerrain', () => { })); test('loads covering tiles with usedForTerrain with source zoom 0-4', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 10; + transform.setZoom(10); const sourceCache = createSourceCache({minzoom: 0, maxzoom: 4}); sourceCache.usedForTerrain = true; @@ -2048,9 +2033,9 @@ describe('SourceCache#usedForTerrain', () => { })); test('loads covering tiles with usedForTerrain with source zoom 4-4', () => new Promise(done => { - const transform = new Transform(); + const transform = new MercatorTransform(); transform.resize(511, 511); - transform.zoom = 10; + transform.setZoom(10); const sourceCache = createSourceCache({minzoom: 4, maxzoom: 4}); sourceCache.usedForTerrain = true; diff --git a/src/source/source_cache.ts b/src/source/source_cache.ts index 6d73b9af38..51c56f9487 100644 --- a/src/source/source_cache.ts +++ b/src/source/source_cache.ts @@ -17,7 +17,7 @@ import type {Source} from './source'; import type {Map} from '../ui/map'; import type {Style} from '../style/style'; import type {Dispatcher} from '../util/dispatcher'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform, ITransform} from '../geo/transform_interface'; import type {TileState} from './tile'; import type {SourceSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {MapSourceDataEvent} from '../ui/events'; @@ -65,7 +65,7 @@ export class SourceCache extends Evented { _paused: boolean; _shouldReloadOnResume: boolean; _coveredTiles: {[_: string]: boolean}; - transform: Transform; + transform: ITransform; terrain: Terrain; used: boolean; usedForTerrain: boolean; @@ -439,7 +439,7 @@ export class SourceCache extends Evented { * are more likely to be found on devices with more memory and on pages where * the map is more important. */ - updateCacheSize(transform: Transform) { + updateCacheSize(transform: IReadonlyTransform) { const widthInTiles = Math.ceil(transform.width / this._source.tileSize) + 1; const heightInTiles = Math.ceil(transform.height / this._source.tileSize) + 1; const approxTilesInView = widthInTiles * heightInTiles; @@ -588,7 +588,7 @@ export class SourceCache extends Evented { * Removes tiles that are outside the viewport and adds new tiles that * are inside the viewport. */ - update(transform: Transform, terrain?: Terrain) { + update(transform: ITransform, terrain?: Terrain) { if (!this._sourceLoaded || this._paused) { return; } @@ -959,8 +959,8 @@ export class SourceCache extends Evented { transform.getCameraQueryGeometry(pointQueryGeometry) : pointQueryGeometry; - const queryGeometry = pointQueryGeometry.map((p: Point) => transform.pointCoordinate(p, this.terrain)); - const cameraQueryGeometry = cameraPointQueryGeometry.map((p: Point) => transform.pointCoordinate(p, this.terrain)); + const queryGeometry = pointQueryGeometry.map((p: Point) => transform.screenPointToMercatorCoordinate(p, this.terrain)); + const cameraQueryGeometry = cameraPointQueryGeometry.map((p: Point) => transform.screenPointToMercatorCoordinate(p, this.terrain)); const ids = this.getIds(); @@ -1012,8 +1012,8 @@ export class SourceCache extends Evented { getVisibleCoordinates(symbolLayer?: boolean): Array { const coords = this.getRenderableIds(symbolLayer).map((id) => this._tiles[id].tileID); - for (const coord of coords) { - coord.posMatrix = this.transform.calculatePosMatrix(coord.toUnwrapped()); + if (this.transform) { + this.transform.precacheTiles(coords); } return coords; } diff --git a/src/source/terrain_source_cache.test.ts b/src/source/terrain_source_cache.test.ts index 3e0b433e4d..0671907e20 100644 --- a/src/source/terrain_source_cache.test.ts +++ b/src/source/terrain_source_cache.test.ts @@ -3,37 +3,14 @@ import {Style} from '../style/style'; import {RequestManager} from '../util/request_manager'; import {Dispatcher} from '../util/dispatcher'; import {fakeServer, type FakeServer} from 'nise'; -import {Transform} from '../geo/transform'; -import {Evented} from '../util/evented'; -import {Painter} from '../render/painter'; import {RasterDEMTileSource} from './raster_dem_tile_source'; import {OverscaledTileID} from './tile_id'; import {Tile} from './tile'; import {DEMData} from '../data/dem_data'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; +import {StubMap} from '../util/test/util'; -const transform = new Transform(); - -class StubMap extends Evented { - transform: Transform; - painter: Painter; - _requestManager: RequestManager; - - constructor() { - super(); - this.transform = transform; - this._requestManager = { - transformRequest: (url) => { - return {url}; - } - } as any as RequestManager; - } - - _getMapId() { - return 1; - } - - setTerrain() {} -} +const transform = new MercatorTransform(); function createSource(options, transformCallback?) { const source = new RasterDEMTileSource('id', options, {send() {}} as any as Dispatcher, null); diff --git a/src/source/terrain_source_cache.ts b/src/source/terrain_source_cache.ts index 6378a18f66..8d95ac6f21 100644 --- a/src/source/terrain_source_cache.ts +++ b/src/source/terrain_source_cache.ts @@ -3,9 +3,10 @@ import {Tile} from './tile'; import {EXTENT} from '../data/extent'; import {mat4} from 'gl-matrix'; import {Evented} from '../util/evented'; -import type {Transform} from '../geo/transform'; +import type {ITransform} from '../geo/transform_interface'; import type {SourceCache} from '../source/source_cache'; import {Terrain} from '../render/terrain'; +import {browser} from '../util/browser'; /** * @internal @@ -50,6 +51,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(); @@ -75,7 +80,7 @@ export class TerrainSourceCache extends Evented { * @param transform - the operation to do * @param terrain - the terrain */ - update(transform: Transform, terrain: Terrain): void { + update(transform: ITransform, terrain: Terrain): void { // load raster-dem tiles for the current scene. this.sourceCache.update(transform, terrain); // create internal render-to-texture tiles for the current scene. @@ -91,9 +96,10 @@ export class TerrainSourceCache extends Evented { keys[tileID.key] = true; this._renderableTilesKeys.push(tileID.key); if (!this._tiles[tileID.key]) { - tileID.posMatrix = new Float64Array(16) as any; - mat4.ortho(tileID.posMatrix, 0, EXTENT, 0, EXTENT, 0, 1); + tileID.terrainRttPosMatrix = new Float64Array(16) as any; + mat4.ortho(tileID.terrainRttPosMatrix, 0, EXTENT, EXTENT, 0, 0, 1); this._tiles[tileID.key] = new Tile(tileID, this.tileSize); + this._lastTilesetChange = browser.now(); } } // free unused tiles @@ -142,29 +148,29 @@ export class TerrainSourceCache extends Evented { const _tileID = this._tiles[key].tileID; if (_tileID.canonical.equals(tileID.canonical)) { const coord = tileID.clone(); - coord.posMatrix = new Float64Array(16) as any; - mat4.ortho(coord.posMatrix, 0, EXTENT, 0, EXTENT, 0, 1); + coord.terrainRttPosMatrix = new Float64Array(16) as any; + mat4.ortho(coord.terrainRttPosMatrix, 0, EXTENT, EXTENT, 0, 0, 1); coords[key] = coord; } else if (_tileID.canonical.isChildOf(tileID.canonical)) { const coord = tileID.clone(); - coord.posMatrix = new Float64Array(16) as any; + coord.terrainRttPosMatrix = new Float64Array(16) as any; const dz = _tileID.canonical.z - tileID.canonical.z; const dx = _tileID.canonical.x - (_tileID.canonical.x >> dz << dz); const dy = _tileID.canonical.y - (_tileID.canonical.y >> dz << dz); const size = EXTENT >> dz; - mat4.ortho(coord.posMatrix, 0, size, 0, size, 0, 1); - mat4.translate(coord.posMatrix, coord.posMatrix, [-dx * size, -dy * size, 0]); + mat4.ortho(coord.terrainRttPosMatrix, 0, size, size, 0, 0, 1); // Note: we are using `size` instead of `EXTENT` here + mat4.translate(coord.terrainRttPosMatrix, coord.terrainRttPosMatrix, [-dx * size, -dy * size, 0]); coords[key] = coord; } else if (tileID.canonical.isChildOf(_tileID.canonical)) { const coord = tileID.clone(); - coord.posMatrix = new Float64Array(16) as any; + coord.terrainRttPosMatrix = new Float64Array(16) as any; const dz = tileID.canonical.z - _tileID.canonical.z; const dx = tileID.canonical.x - (tileID.canonical.x >> dz << dz); const dy = tileID.canonical.y - (tileID.canonical.y >> dz << dz); const size = EXTENT >> dz; - mat4.ortho(coord.posMatrix, 0, EXTENT, 0, EXTENT, 0, 1); - mat4.translate(coord.posMatrix, coord.posMatrix, [dx * size, dy * size, 0]); - mat4.scale(coord.posMatrix, coord.posMatrix, [1 / (2 ** dz), 1 / (2 ** dz), 0]); + mat4.ortho(coord.terrainRttPosMatrix, 0, EXTENT, EXTENT, 0, 0, 1); + mat4.translate(coord.terrainRttPosMatrix, coord.terrainRttPosMatrix, [dx * size, dy * size, 0]); + mat4.scale(coord.terrainRttPosMatrix, coord.terrainRttPosMatrix, [1 / (2 ** dz), 1 / (2 ** dz), 0]); coords[key] = coord; } } @@ -174,7 +180,7 @@ export class TerrainSourceCache extends Evented { /** * find the covering raster-dem tile * @param tileID - the tile to look for - * @param searchForDEM - Optional parameter to search for (parent) sourcetiles with loaded dem. + * @param searchForDEM - Optional parameter to search for (parent) source tiles with loaded dem. * @returns the tile */ getSourceTile(tileID: OverscaledTileID, searchForDEM?: boolean): Tile { @@ -194,11 +200,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.ts b/src/source/tile.ts index 42e7e8797c..83cc997813 100644 --- a/src/source/tile.ts +++ b/src/source/tile.ts @@ -26,7 +26,7 @@ import type {ImageManager} from '../render/image_manager'; import type {Context} from '../gl/context'; import type {OverscaledTileID} from './tile_id'; import type {Framebuffer} from '../gl/framebuffer'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {LayerFeatureStates} from './source_state'; import type {FilterSpecification} from '@maplibre/maplibre-gl-style-spec'; import type Point from '@mapbox/point-geometry'; @@ -290,7 +290,7 @@ export class Tile { layers: Array; availableImages: Array; }, - transform: Transform, + transform: IReadonlyTransform, maxPitchScaleFactor: number, pixelPosMatrix: mat4 ): {[_: string]: Array<{featureIndex: number; feature: GeoJSONFeature}>} { diff --git a/src/source/tile_cache.ts b/src/source/tile_cache.ts index 8ddac8ab9c..e1a32f1034 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](https://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/tile_id.ts b/src/source/tile_id.ts index f723752f65..d363b888dc 100644 --- a/src/source/tile_id.ts +++ b/src/source/tile_id.ts @@ -26,7 +26,7 @@ export class CanonicalTileID implements ICanonicalTileID { this.z = z; this.x = x; this.y = y; - this.key = calculateKey(0, z, z, x, y); + this.key = calculateTileKey(0, z, z, x, y); } equals(id: ICanonicalTileID) { @@ -77,7 +77,7 @@ export class UnwrappedTileID { constructor(wrap: number, canonical: CanonicalTileID) { this.wrap = wrap; this.canonical = canonical; - this.key = calculateKey(wrap, canonical.z, canonical.z, canonical.x, canonical.y); + this.key = calculateTileKey(wrap, canonical.z, canonical.z, canonical.x, canonical.y); } } @@ -89,14 +89,19 @@ export class OverscaledTileID { wrap: number; canonical: CanonicalTileID; key: string; - posMatrix: mat4; + /** + * This matrix is used during terrain's render-to-texture stage only. + * If the render-to-texture stage is active, this matrix will be present + * and should be used, otherwise this matrix will be null. + */ + terrainRttPosMatrix: mat4 | null = null; constructor(overscaledZ: number, wrap: number, z: number, x: number, y: number) { if (overscaledZ < z) throw new Error(`overscaledZ should be >= z; overscaledZ = ${overscaledZ}; z = ${z}`); this.overscaledZ = overscaledZ; this.wrap = wrap; this.canonical = new CanonicalTileID(z, +x, +y); - this.key = calculateKey(wrap, overscaledZ, z, x, y); + this.key = calculateTileKey(wrap, overscaledZ, z, x, y); } clone() { @@ -126,9 +131,9 @@ export class OverscaledTileID { if (targetZ > this.overscaledZ) throw new Error(`targetZ > this.overscaledZ; targetZ = ${targetZ}; overscaledZ = ${this.overscaledZ}`); const zDifference = this.canonical.z - targetZ; if (targetZ > this.canonical.z) { - return calculateKey(this.wrap * +withWrap, targetZ, this.canonical.z, this.canonical.x, this.canonical.y); + return calculateTileKey(this.wrap * +withWrap, targetZ, this.canonical.z, this.canonical.x, this.canonical.y); } else { - return calculateKey(this.wrap * +withWrap, targetZ, targetZ, this.canonical.x >> zDifference, this.canonical.y >> zDifference); + return calculateTileKey(this.wrap * +withWrap, targetZ, targetZ, this.canonical.x >> zDifference, this.canonical.y >> zDifference); } } @@ -201,7 +206,7 @@ export class OverscaledTileID { } } -function calculateKey(wrap: number, overscaledZ: number, z: number, x: number, y: number): string { +export function calculateTileKey(wrap: number, overscaledZ: number, z: number, x: number, y: number): string { wrap *= 2; if (wrap < 0) wrap = wrap * -1 - 1; const dim = 1 << z; @@ -218,4 +223,4 @@ function getQuadkey(z, x, y) { } register('CanonicalTileID', CanonicalTileID); -register('OverscaledTileID', OverscaledTileID, {omit: ['posMatrix']}); +register('OverscaledTileID', OverscaledTileID, {omit: ['terrainRttPosMatrix']}); diff --git a/src/source/vector_tile_source.test.ts b/src/source/vector_tile_source.test.ts index 2df1dd5daf..3f62ec2321 100644 --- a/src/source/vector_tile_source.test.ts +++ b/src/source/vector_tile_source.test.ts @@ -9,6 +9,7 @@ import fixturesSource from '../../test/unit/assets/source.json' with {type: 'jso import {getMockDispatcher, getWrapDispatcher, sleep, waitForMetadataEvent} from '../util/test/util'; import {Map} from '../ui/map'; import {WorkerTileParameters} from './worker_source'; +import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; import {ActorMessage, MessageType} from '../util/actor_messages'; function createSource(options, transformCallback?, clearTiles = () => {}) { @@ -17,8 +18,15 @@ function createSource(options, transformCallback?, clearTiles = () => {}) { transform: {showCollisionBoxes: false}, _getMapId: () => 1, _requestManager: new RequestManager(transformCallback), - style: {sourceCaches: {id: {clearTiles}}}, - getPixelRatio() { return 1; } + style: { + sourceCaches: {id: {clearTiles}}, + projection: { + get subdivisionGranularity() { + return SubdivisionGranularitySetting.noSubdivision; + } + } + }, + getPixelRatio() { return 1; }, } as any as Map); source.on('error', () => { }); // to prevent console log of errors diff --git a/src/source/vector_tile_source.ts b/src/source/vector_tile_source.ts index b734ec9071..6e54189ce1 100644 --- a/src/source/vector_tile_source.ts +++ b/src/source/vector_tile_source.ts @@ -11,7 +11,7 @@ import type {Map} from '../ui/map'; import type {Dispatcher} from '../util/dispatcher'; import type {Tile} from './tile'; import type {VectorSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; -import type {WorkerTileResult} from './worker_source'; +import type {WorkerTileParameters, WorkerTileResult} from './worker_source'; import {MessageType} from '../util/actor_messages'; export type VectorTileSourceOptions = VectorSourceSpecification & { @@ -190,7 +190,7 @@ export class VectorTileSource extends Evented implements Source { async loadTile(tile: Tile): Promise { const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); - const params = { + const params: WorkerTileParameters = { request: this.map._requestManager.transformRequest(url, ResourceType.Tile), uid: tile.uid, tileID: tile.tileID, @@ -200,7 +200,8 @@ export class VectorTileSource extends Evented implements Source { source: this.id, pixelRatio: this.map.getPixelRatio(), showCollisionBoxes: this.map.showCollisionBoxes, - promoteId: this.promoteId + promoteId: this.promoteId, + subdivisionGranularity: this.map.style.projection.subdivisionGranularity }; params.request.collectResourceTiming = this._collectResourceTiming; let messageType: MessageType.loadTile | MessageType.reloadTile = MessageType.reloadTile; diff --git a/src/source/vector_tile_worker_source.test.ts b/src/source/vector_tile_worker_source.test.ts index defc3a9798..ae56663964 100644 --- a/src/source/vector_tile_worker_source.test.ts +++ b/src/source/vector_tile_worker_source.test.ts @@ -10,6 +10,7 @@ import {TileParameters, WorkerTileParameters, WorkerTileResult} from './worker_s import {WorkerTile} from './worker_tile'; import {setPerformance, sleep} from '../util/test/util'; import {ABORT_ERROR} from '../util/abort_error'; +import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; describe('vector tile worker source', () => { const actor = {sendAsync: () => Promise.resolve({})} as IActor; @@ -140,7 +141,8 @@ describe('vector tile worker source', () => { source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, - request: {url: 'http://localhost:2900/faketile.pbf'} + request: {url: 'http://localhost:2900/faketile.pbf'}, + subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision, } as any as WorkerTileParameters).then(() => expect(false).toBeTruthy()); // allow promise to run @@ -150,6 +152,7 @@ describe('vector tile worker source', () => { source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, + subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision, } as any as WorkerTileParameters); expect(res).toBeDefined(); expect(res.rawTileData).toBeDefined(); diff --git a/src/source/vector_tile_worker_source.ts b/src/source/vector_tile_worker_source.ts index 676447da80..616e68d445 100644 --- a/src/source/vector_tile_worker_source.ts +++ b/src/source/vector_tile_worker_source.ts @@ -125,7 +125,7 @@ export class VectorTileWorkerSource implements WorkerSource { } workerTile.vectorTile = response.vectorTile; - const parsePromise = workerTile.parse(response.vectorTile, this.layerIndex, this.availableImages, this.actor); + const parsePromise = workerTile.parse(response.vectorTile, this.layerIndex, this.availableImages, this.actor, params.subdivisionGranularity); this.loaded[tileUid] = workerTile; // keep the original fetching state so that reload tile can pick it up if the original parse is cancelled by reloads' parse this.fetching[tileUid] = {rawTileData, cacheControl, resourceTiming}; @@ -156,7 +156,7 @@ export class VectorTileWorkerSource implements WorkerSource { const workerTile = this.loaded[uid]; workerTile.showCollisionBoxes = params.showCollisionBoxes; if (workerTile.status === 'parsing') { - const result = await workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor); + const result = await workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor, params.subdivisionGranularity); // if we have cancelled the original parse, make sure to pass the rawTileData from the original fetch let parseResult: WorkerTileResult; if (this.fetching[uid]) { @@ -172,7 +172,7 @@ export class VectorTileWorkerSource implements WorkerSource { // if there was no vector tile data on the initial load, don't try and re-parse tile if (workerTile.status === 'done' && workerTile.vectorTile) { // this seems like a missing case where cache control is lost? see #3309 - return workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor); + return workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor, params.subdivisionGranularity); } } diff --git a/src/source/video_source.test.ts b/src/source/video_source.test.ts index 69d6d3b78c..11fc9becc6 100644 --- a/src/source/video_source.test.ts +++ b/src/source/video_source.test.ts @@ -6,18 +6,17 @@ import type {Coordinates} from './image_source'; 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'; +import {IReadonlyTransform} from '../geo/transform_interface'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; class StubMap extends Evented { - transform: Transform; + transform: IReadonlyTransform; style: any; painter: any; constructor() { super(); - this.transform = new Transform(); + this.transform = new MercatorTransform(); this.style = {}; this.painter = { context: { @@ -113,8 +112,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 d234084c6c..2425966021 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'; @@ -159,14 +157,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/source/worker_source.ts b/src/source/worker_source.ts index 10bbd5518d..a7f1108f99 100644 --- a/src/source/worker_source.ts +++ b/src/source/worker_source.ts @@ -13,6 +13,7 @@ import type {PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {RemoveSourceParams} from '../util/actor_messages'; import type {IActor} from '../util/actor'; import type {StyleLayerIndex} from '../style/style_layer_index'; +import type {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; /** * Parameters to identify a tile @@ -37,10 +38,11 @@ export type WorkerTileParameters = TileParameters & { showCollisionBoxes: boolean; collectResourceTiming?: boolean; returnDependencies?: boolean; + subdivisionGranularity: SubdivisionGranularitySetting; }; /** - * The paremeters needed in order to load a DEM tile + * The parameters needed in order to load a DEM tile */ export type WorkerDEMTileParameters = TileParameters & { rawImageData: RGBAImage | ImageBitmap | ImageData; diff --git a/src/source/worker_tile.test.ts b/src/source/worker_tile.test.ts index 813bc71e56..0a6e4f6bae 100644 --- a/src/source/worker_tile.test.ts +++ b/src/source/worker_tile.test.ts @@ -4,6 +4,7 @@ import {OverscaledTileID} from '../source/tile_id'; import {StyleLayerIndex} from '../style/style_layer_index'; import {WorkerTileParameters} from './worker_source'; import {VectorTile} from '@mapbox/vector-tile'; +import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; function createWorkerTile() { return new WorkerTile({ @@ -34,7 +35,7 @@ describe('worker tile', () => { }]); const tile = createWorkerTile(); - const result = await tile.parse(createWrapper(), layerIndex, [], {} as any); + const result = await tile.parse(createWrapper(), layerIndex, [], {} as any, SubdivisionGranularitySetting.noSubdivision); expect(result.buckets[0]).toBeTruthy(); }); @@ -47,7 +48,7 @@ describe('worker tile', () => { }]); const tile = createWorkerTile(); - const result = await tile.parse(createWrapper(), layerIndex, [], {} as any); + const result = await tile.parse(createWrapper(), layerIndex, [], {} as any, SubdivisionGranularitySetting.noSubdivision); expect(result.buckets).toHaveLength(0); }); @@ -60,7 +61,7 @@ describe('worker tile', () => { }]); const tile = createWorkerTile(); - const result = await tile.parse({layers: {}}, layerIndex, [], {} as any); + const result = await tile.parse({layers: {}}, layerIndex, [], {} as any, SubdivisionGranularitySetting.noSubdivision); expect(result.buckets).toHaveLength(0); }); @@ -83,7 +84,7 @@ describe('worker tile', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const tile = createWorkerTile(); - await tile.parse(data, layerIndex, [], {} as any); + await tile.parse(data, layerIndex, [], {} as any, SubdivisionGranularitySetting.noSubdivision); expect(spy.mock.calls[0][0]).toMatch(/does not use vector tile spec v2/); }); @@ -141,7 +142,7 @@ describe('worker tile', () => { const actorMock = { sendAsync }; - const result = await tile.parse(data, layerIndex, ['hello'], actorMock); + const result = await tile.parse(data, layerIndex, ['hello'], actorMock, SubdivisionGranularitySetting.noSubdivision); expect(result).toBeDefined(); expect(sendAsync).toHaveBeenCalledTimes(3); expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'icons'})}), expect.any(Object)); @@ -213,9 +214,9 @@ describe('worker tile', () => { const actorMock = { sendAsync }; - tile.parse(data, layerIndex, ['hello'], actorMock).then(() => expect(false).toBeTruthy()); - tile.parse(data, layerIndex, ['hello'], actorMock).then(() => expect(false).toBeTruthy()); - const result = await tile.parse(data, layerIndex, ['hello'], actorMock); + tile.parse(data, layerIndex, ['hello'], actorMock, SubdivisionGranularitySetting.noSubdivision).then(() => expect(false).toBeTruthy()); + tile.parse(data, layerIndex, ['hello'], actorMock, SubdivisionGranularitySetting.noSubdivision).then(() => expect(false).toBeTruthy()); + const result = await tile.parse(data, layerIndex, ['hello'], actorMock, SubdivisionGranularitySetting.noSubdivision); expect(result).toBeDefined(); expect(cancelCount).toBe(6); expect(sendAsync).toHaveBeenCalledTimes(9); diff --git a/src/source/worker_tile.ts b/src/source/worker_tile.ts index d0e86bbdb8..33f24f4161 100644 --- a/src/source/worker_tile.ts +++ b/src/source/worker_tile.ts @@ -23,6 +23,7 @@ import type { import type {PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {VectorTile} from '@mapbox/vector-tile'; import {MessageType, type GetGlyphsResponse, type GetImagesResponse} from '../util/actor_messages'; +import type {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; export class WorkerTile { tileID: OverscaledTileID; @@ -60,7 +61,7 @@ export class WorkerTile { this.inFlightDependencies = []; } - async parse(data: VectorTile, layerIndex: StyleLayerIndex, availableImages: Array, actor: IActor): Promise { + async parse(data: VectorTile, layerIndex: StyleLayerIndex, availableImages: Array, actor: IActor, subdivisionGranularity: SubdivisionGranularitySetting): Promise { this.status = 'parsing'; this.data = data; @@ -77,7 +78,8 @@ export class WorkerTile { iconDependencies: {}, patternDependencies: {}, glyphDependencies: {}, - availableImages + availableImages, + subdivisionGranularity }; const layerFamilies = layerIndex.familiesBySource[this.source]; @@ -173,7 +175,8 @@ export class WorkerTile { imageMap: iconMap, imagePositions: imageAtlas.iconPositions, showCollisionBoxes: this.showCollisionBoxes, - canonical: this.tileID.canonical + canonical: this.tileID.canonical, + subdivisionGranularity: options.subdivisionGranularity }); } else if (bucket.hasPattern && (bucket instanceof LineBucket || diff --git a/src/style/pauseable_placement.ts b/src/style/pauseable_placement.ts index 3db76391f9..3fd5927a70 100644 --- a/src/style/pauseable_placement.ts +++ b/src/style/pauseable_placement.ts @@ -1,14 +1,11 @@ import {browser} from '../util/browser'; - import {Placement} from '../symbol/placement'; - -import type {Transform} from '../geo/transform'; +import type {ITransform} from '../geo/transform_interface'; import type {StyleLayer} from './style_layer'; import type {SymbolStyleLayer} from './style_layer/symbol_style_layer'; import type {Tile} from '../source/tile'; import type {BucketPart} from '../symbol/placement'; -import {Terrain} from '../render/terrain'; -import {createProjection} from '../geo/projection/projection'; +import type {Terrain} from '../render/terrain'; class LayerPlacement { _sortAcrossTiles: boolean; @@ -70,7 +67,7 @@ export class PauseablePlacement { _inProgressLayer: LayerPlacement; constructor( - transform: Transform, + transform: ITransform, terrain: Terrain, order: Array, forceFullPlacement: boolean, @@ -79,7 +76,7 @@ export class PauseablePlacement { crossSourceCollisions: boolean, prevPlacement?: Placement ) { - this.placement = new Placement(transform, createProjection(), terrain, fadeDuration, crossSourceCollisions, prevPlacement); + this.placement = new Placement(transform, terrain, fadeDuration, crossSourceCollisions, prevPlacement); this._currentPlacementIndex = order.length - 1; this._forceFullPlacement = forceFullPlacement; this._showCollisionBoxes = showCollisionBoxes; diff --git a/src/style/sky.test.ts b/src/style/sky.test.ts new file mode 100644 index 0000000000..e8cde2eb9c --- /dev/null +++ b/src/style/sky.test.ts @@ -0,0 +1,85 @@ +import {Sky} from './sky'; +import {latest as styleSpec, SkySpecification} from '@maplibre/maplibre-gl-style-spec'; +import {EvaluationParameters} from './evaluation_parameters'; +import {TransitionParameters} from './properties'; + +const spec = styleSpec.sky; + +test('Sky with defaults', () => { + const sky = new Sky({}); + sky.recalculate({zoom: 0, zoomHistory: {}} as EvaluationParameters); + + expect(sky.properties.get('atmosphere-blend')).toEqual(spec['atmosphere-blend'].default); +}); + +test('Sky with options', () => { + const sky = new Sky({ + 'atmosphere-blend': 0.4 + }); + sky.recalculate({zoom: 0, zoomHistory: {}} as EvaluationParameters); + + expect(sky.properties.get('atmosphere-blend')).toBe(0.4); +}); + +test('Sky with interpolate function', () => { + const sky = new Sky({ + 'atmosphere-blend': [ + 'interpolate', + ['linear'], + ['zoom'], + 0, 1, + 5, 1, + 7, 0 + ] + } as SkySpecification); + sky.recalculate({zoom: 6, zoomHistory: {}} as EvaluationParameters); + + expect(sky.properties.get('atmosphere-blend')).toBe(0.5); +}); + +test('Sky#getSky', () => { + const defaults = {'atmosphere-blend': 0.8}; + + expect(new Sky(defaults).getSky()).toEqual(defaults); +}); + +describe('Sky#setSky', () => { + test('sets Sky', () => { + const sky = new Sky({}); + sky.setSky({'atmosphere-blend': 1} as SkySpecification); + sky.updateTransitions({ + now: 0, + transition: { + duration: 3000, + delay: 0 + } + } as any as TransitionParameters); + sky.recalculate({zoom: 16, zoomHistory: {}, now: 1500} as EvaluationParameters); + expect(sky.properties.get('atmosphere-blend')).toBe(0.9); + }); + + test('validates by default', () => { + const sky = new Sky({}); + const skySpy = jest.spyOn(sky, '_validate'); + jest.spyOn(console, 'error').mockImplementation(() => { }); + sky.setSky({'atmosphere-blend': -1}); + sky.updateTransitions({transition: false} as any as TransitionParameters); + sky.recalculate({zoom: 16, zoomHistory: {}, now: 10} as EvaluationParameters); + expect(skySpy).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledTimes(1); + expect(skySpy.mock.calls[0][2]).toEqual({}); + }); + + test('respects validation option', () => { + const sky = new Sky({}); + + const skySpy = jest.spyOn(sky, '_validate'); + sky.setSky({'atmosphere-blend': -1} as any, {validate: false}); + sky.updateTransitions({transition: false} as any as TransitionParameters); + sky.recalculate({zoom: 16, zoomHistory: {}, now: 10} as EvaluationParameters); + + expect(skySpy).toHaveBeenCalledTimes(1); + expect(skySpy.mock.calls[0][2]).toEqual({validate: false}); + expect(sky.properties.get('atmosphere-blend')).toBe(-1); + }); +}); diff --git a/src/style/sky.ts b/src/style/sky.ts index b11ee784d2..f65e88daad 100644 --- a/src/style/sky.ts +++ b/src/style/sky.ts @@ -47,6 +47,7 @@ export class Sky extends Evented { * This is used to cache the gl mesh for the sky, it should be initialized only once. */ mesh: Mesh | undefined; + atmosphereMesh: Mesh | undefined; _transitionable: Transitionable; _transitioning: Transitioning; diff --git a/src/style/style.test.ts b/src/style/style.test.ts index 57f889c3d8..4fc7dbdb0e 100644 --- a/src/style/style.test.ts +++ b/src/style/style.test.ts @@ -1,10 +1,8 @@ import {Style} from './style'; import {SourceCache} from '../source/source_cache'; import {StyleLayer} from './style_layer'; -import {Transform} from '../geo/transform'; import {extend} from '../util/util'; -import {RequestManager} from '../util/request_manager'; -import {Event, Evented} from '../util/evented'; +import {Event} from '../util/evented'; import {RGBAImage} from '../util/image'; import {rtlMainThreadPluginFactory} from '../source/rtl_text_plugin_main_thread'; import {browser} from '../util/browser'; @@ -12,11 +10,12 @@ import {OverscaledTileID} from '../source/tile_id'; import {fakeServer, type FakeServer} from 'nise'; import {EvaluationParameters} from './evaluation_parameters'; -import {LayerSpecification, GeoJSONSourceSpecification, FilterSpecification, SourceSpecification, StyleSpecification, SymbolLayerSpecification, TerrainSpecification, SkySpecification} from '@maplibre/maplibre-gl-style-spec'; +import {LayerSpecification, GeoJSONSourceSpecification, FilterSpecification, SourceSpecification, StyleSpecification, SymbolLayerSpecification, SkySpecification} from '@maplibre/maplibre-gl-style-spec'; import {GeoJSONSource} from '../source/geojson_source'; -import {sleep} from '../util/test/util'; +import {StubMap, sleep} from '../util/test/util'; import {RTLPluginLoadedEventName} from '../source/rtl_text_plugin_status'; import {MessageType} from '../util/actor_messages'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; function createStyleJSON(properties?): StyleSpecification { return extend({ @@ -46,30 +45,6 @@ function createGeoJSONSource() { }; } -class StubMap extends Evented { - style: Style; - transform: Transform; - private _requestManager: RequestManager; - _terrain: TerrainSpecification; - - constructor() { - super(); - this.transform = new Transform(); - this._requestManager = new RequestManager(); - } - - _getMapId() { - return 1; - } - - getPixelRatio() { - return 1; - } - - setTerrain(terrain) { this._terrain = terrain; } - getTerrain() { return this._terrain; } -} - const getStubMap = () => new StubMap() as any; function createStyle(map = getStubMap()) { @@ -583,6 +558,20 @@ describe('Style#_load', () => { style._load(styleSpec, {validate: false}); expect(style._serializedLayers).toBeNull(); }); + + test('projection is mercator if not specified', () => { + const style = new Style(getStubMap()); + const styleSpec = createStyleJSON({ + layers: [{ + id: 'background', + type: 'background' + }] + }); + + style._load(styleSpec, {validate: false}); + expect(style.projection.name).toBe('mercator'); + expect(style.serialize().projection).toBeUndefined(); + }); }); describe('Style#_remove', () => { @@ -675,6 +664,7 @@ describe('Style#setState', () => { spys.push(jest.spyOn(style, 'setGeoJSONSourceData').mockImplementation((() => {}) as any)); spys.push(jest.spyOn(style, 'setLayerZoomRange').mockImplementation((() => {}) as any)); spys.push(jest.spyOn(style, 'setLight').mockImplementation((() => {}) as any)); + spys.push(jest.spyOn(style, 'setSky').mockImplementation((() => {}) as any)); await style.once('style.load'); const didChange = style.setState(createStyleJSON()); expect(didChange).toBeFalsy(); @@ -703,6 +693,9 @@ describe('Style#setState', () => { }, light: { anchor: 'viewport' + }, + sky: { + 'atmosphere-blend': 0 } }); style.loadJSON(styleJson); @@ -721,8 +714,9 @@ describe('Style#setState', () => { spys.push(jest.spyOn(style, 'setGeoJSONSourceData').mockImplementation((() => {}) as any)); spys.push(jest.spyOn(style, 'setGlyphs').mockImplementation((() => {}) as any)); spys.push(jest.spyOn(style, 'setSprite').mockImplementation((() => {}) as any)); - spys.push(jest.spyOn(style, 'setSky').mockImplementation((() => {}) as any)); + spys.push(jest.spyOn(style, 'setProjection').mockImplementation((() => {}) as any)); spys.push(jest.spyOn(style.map, 'setTerrain').mockImplementation((() => {}) as any)); + spys.push(jest.spyOn(style, 'setSky').mockImplementation((() => {}) as any)); const newStyle = JSON.parse(JSON.stringify(styleJson)) as StyleSpecification; newStyle.layers[0].paint = {'text-color': '#7F7F7F',}; @@ -750,10 +744,13 @@ describe('Style#setState', () => { exaggeration: 0.5 }; newStyle.zoom = 2; + newStyle.projection = {type: 'globe'}; + newStyle.sky = { 'fog-color': '#000001', 'sky-color': '#000002', 'horizon-fog-blend': 0.5, + 'atmosphere-blend': 1 }; const didChange = style.setState(newStyle); expect(didChange).toBeTruthy(); @@ -782,6 +779,7 @@ describe('Style#setState', () => { spys.push(jest.spyOn(style, 'setGlyphs').mockImplementation((() => {}) as any)); spys.push(jest.spyOn(style, 'setSprite').mockImplementation((() => {}) as any)); spys.push(jest.spyOn(style.map, 'setTerrain').mockImplementation((() => {}) as any)); + spys.push(jest.spyOn(style, 'setSky').mockImplementation((() => {}) as any)); const newStyleJson = createStyleJSON(); newStyleJson.transition = {duration: 5}; @@ -1723,7 +1721,7 @@ describe('Style#setPaintProperty', () => { ] })); - const tr = new Transform(); + const tr = new MercatorTransform(); tr.resize(512, 512); style.once('style.load', () => { @@ -2189,7 +2187,7 @@ describe('Style#queryRenderedFeatures', () => { beforeEach(() => new Promise(callback => { style = new Style(getStubMap()); - transform = new Transform(); + transform = new MercatorTransform(); transform.resize(512, 512); function queryMapLibreFeatures(layers, serializedLayers, getFeatureState, queryGeom, cameraQueryGeom, scale, params) { const features = { @@ -2422,7 +2420,7 @@ describe('Style#query*Features', () => { let transform; beforeEach(() => new Promise(callback => { - transform = new Transform(); + transform = new MercatorTransform(); transform.resize(100, 100); style = new Style(getStubMap()); style.loadJSON({ @@ -2563,6 +2561,30 @@ describe('Style#serialize', () => { expect(style.serialize().terrain).toBeUndefined(); }); + test('include projection property when projection is defined in the style', async () => { + const style = new Style(getStubMap()); + style.loadJSON(createStyleJSON({ + projection: { + type: 'globe' + } + })); + + await style.once('style.load'); + expect(style.serialize().projection).toBeDefined(); + expect(style.serialize().projection.type).toBe('globe'); + }); + + test('include projection property when projection is set', async () => { + const style = new Style(getStubMap()); + style.loadJSON(createStyleJSON()); + + await style.once('style.load'); + style.setProjection({type: 'globe'}); + + expect(style.serialize().projection).toBeDefined(); + expect(style.serialize().projection.type).toBe('globe'); + }); + test('include sky property when map has sky', async () => { const sky: SkySpecification = { 'horizon-fog-blend': 0.5, @@ -2573,6 +2595,21 @@ describe('Style#serialize', () => { style.loadJSON(styleJson); await style.once('style.load'); + expect(style.serialize().sky).toBe(sky); + }); + + test('include sky property when sky is set', async () => { + const sky = { + 'atmosphere-blend': 0.5, + }; + const style = new Style(getStubMap()); + style.loadJSON(createStyleJSON()); + + await style.once('style.load'); + style.setSky(sky); + + expect(style.serialize().sky).toBeDefined(); + expect(style.serialize().sky).toBe(sky); expect(style.serialize().sky).toStrictEqual(sky); }); diff --git a/src/style/style.ts b/src/style/style.ts index ff9638ed2c..63983b1c2d 100644 --- a/src/style/style.ts +++ b/src/style/style.ts @@ -38,7 +38,7 @@ const emitValidationErrors = (evented: Evented, errors?: ReadonlyArray<{ _emitValidationErrors(evented, errors && errors.filter(error => error.identifier !== 'source.canvas')); import type {Map} from '../ui/map'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform, ITransform} from '../geo/transform_interface'; import type {StyleImage} from './style_image'; import type {EvaluationParameters} from './evaluation_parameters'; import type {Placement} from '../symbol/placement'; @@ -50,6 +50,7 @@ import type { SourceSpecification, SpriteSpecification, DiffOperations, + ProjectionSpecification, SkySpecification } from '@maplibre/maplibre-gl-style-spec'; import type {CanvasSourceSpecification} from '../source/canvas_source'; @@ -62,6 +63,8 @@ import { type GetImagesParameters, type GetImagesResponse } from '../util/actor_messages'; +import {Projection} from '../geo/projection/projection'; +import {createProjectionFromName} from '../geo/projection/projection_factory'; const empty = emptyStyle() as StyleSpecification; /** @@ -189,6 +192,7 @@ export class Style extends Evented { glyphManager: GlyphManager; lineAtlas: LineAtlas; light: Light; + projection: Projection; sky: Sky; _frameRequest: AbortController; @@ -344,6 +348,8 @@ export class Style extends Evented { this._createLayers(); this.light = new Light(this.stylesheet.light); + this._setProjectionInternal(this.stylesheet.projection?.type || 'mercator'); + this.sky = new Sky(this.stylesheet.sky); this.map.setTerrain(this.stylesheet.terrain ?? null); @@ -778,11 +784,14 @@ export class Style extends Evented { case 'setSprite': operations.push(() => this.setSprite.apply(this, op.args)); break; + case 'setTerrain': + operations.push(() => this.map.setTerrain.apply(this, op.args)); + break; case 'setSky': operations.push(() => this.setSky.apply(this, op.args)); break; - case 'setTerrain': - operations.push(() => this.map.setTerrain.apply(this, op.args)); + case 'setProjection': + this.setProjection.apply(this, op.args); break; case 'setTransition': operations.push(() => {}); @@ -1304,6 +1313,7 @@ export class Style extends Evented { sprite: myStyleSheet.sprite, glyphs: myStyleSheet.glyphs, transition: myStyleSheet.transition, + projection: myStyleSheet.projection, sources, layers, terrain @@ -1394,7 +1404,7 @@ export class Style extends Evented { return features; } - queryRenderedFeatures(queryGeometry: any, params: QueryRenderedFeaturesOptions, transform: Transform) { + queryRenderedFeatures(queryGeometry: any, params: QueryRenderedFeaturesOptions, transform: IReadonlyTransform) { if (params && params.filter) { this._validate(validateStyle.filter, 'queryRenderedFeatures.filter', params.filter, null, params); } @@ -1494,12 +1504,29 @@ export class Style extends Evented { this.light.updateTransitions(parameters); } + getProjection(): ProjectionSpecification { + return this.stylesheet.projection; + } + + setProjection(projection: ProjectionSpecification) { + this._checkLoaded(); + if (this.projection) { + if (this.projection.name === projection.type) return; + this.projection.destroy(); + delete this.projection; + } + this.stylesheet.projection = projection; + this._setProjectionInternal(projection.type); + } + getSky(): SkySpecification { return this.stylesheet?.sky; } setSky(skyOptions?: SkySpecification, options: StyleSetterOptions = {}) { + this._checkLoaded(); const sky = this.getSky(); + let update = false; if (!skyOptions && !sky) return; @@ -1530,6 +1557,15 @@ export class Style extends Evented { this.sky.updateTransitions(parameters); } + _setProjectionInternal(name: ProjectionSpecification['type']) { + const projectionObjects = createProjectionFromName(name); + this.projection = projectionObjects.projection; + this.map.migrateProjection(projectionObjects.transform, projectionObjects.cameraHelper); + for (const key in this.sourceCaches) { + this.sourceCaches[key].reload(); + } + } + _validate(validate: Validator, key: string, value: any, props: any, options: { validate?: boolean; } = {}) { @@ -1584,7 +1620,7 @@ export class Style extends Evented { this.sourceCaches[id].reload(); } - _updateSources(transform: Transform) { + _updateSources(transform: ITransform) { for (const id in this.sourceCaches) { this.sourceCaches[id].update(transform, this.map.terrain); } @@ -1596,7 +1632,7 @@ export class Style extends Evented { } } - _updatePlacement(transform: Transform, showCollisionBoxes: boolean, fadeDuration: number, crossSourceCollisions: boolean, forceFullPlacement: boolean = false) { + _updatePlacement(transform: ITransform, showCollisionBoxes: boolean, fadeDuration: number, crossSourceCollisions: boolean, forceFullPlacement: boolean = false) { let symbolBucketsChanged = false; let placementCommitted = false; @@ -1695,14 +1731,14 @@ export class Style extends Evented { } async getGlyphs(mapId: string | number, params: GetGlyphsParameters): Promise { - const glypgs = await this.glyphManager.getGlyphs(params.stacks); + const glyphs = await this.glyphManager.getGlyphs(params.stacks); const sourceCache = this.sourceCaches[params.source]; if (sourceCache) { // we are not setting stacks as dependencies since for now // we just need to know which tiles have glyph dependencies sourceCache.setDependencies(params.tileID.key, params.type, ['']); } - return glypgs; + return glyphs; } getGlyphsUrl() { diff --git a/src/style/style_layer.ts b/src/style/style_layer.ts index 277daae6e6..ed58c117f7 100644 --- a/src/style/style_layer.ts +++ b/src/style/style_layer.ts @@ -19,7 +19,7 @@ import type {TransitionParameters, PropertyValue} from './properties'; import {EvaluationParameters} from './evaluation_parameters'; import type {CrossfadeParameters} from './evaluation_parameters'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {CustomLayerInterface} from './style_layer/custom_style_layer'; import type {Map} from '../ui/map'; import type {StyleSetterOptions} from './style'; @@ -62,7 +62,7 @@ export abstract class StyleLayer extends Evented { featureState: FeatureState, geometry: Array>, zoom: number, - transform: Transform, + transform: IReadonlyTransform, pixelsToTileUnits: number, pixelPosMatrix: mat4 ): boolean | number; diff --git a/src/style/style_layer/circle_style_layer.ts b/src/style/style_layer/circle_style_layer.ts index 2a7b9cfa9a..fb11dfa680 100644 --- a/src/style/style_layer/circle_style_layer.ts +++ b/src/style/style_layer/circle_style_layer.ts @@ -8,7 +8,7 @@ import {Transitionable, Transitioning, Layout, PossiblyEvaluated} from '../prope import {mat4, vec4} from 'gl-matrix'; import Point from '@mapbox/point-geometry'; import type {FeatureState, LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; -import type {Transform} from '../../geo/transform'; +import type {IReadonlyTransform} from '../../geo/transform_interface'; import type {Bucket, BucketParameters} from '../../data/bucket'; import type {CircleLayoutProps, CirclePaintProps} from './circle_style_layer_properties.g'; import type {VectorTileFeature} from '@mapbox/vector-tile'; @@ -45,7 +45,7 @@ export class CircleStyleLayer extends StyleLayer { featureState: FeatureState, geometry: Array>, zoom: number, - transform: Transform, + transform: IReadonlyTransform, pixelsToTileUnits: number, pixelPosMatrix: mat4 ): boolean { diff --git a/src/style/style_layer/custom_style_layer.ts b/src/style/style_layer/custom_style_layer.ts index 0c5e634f01..a1823e15d2 100644 --- a/src/style/style_layer/custom_style_layer.ts +++ b/src/style/style_layer/custom_style_layer.ts @@ -2,11 +2,12 @@ import {StyleLayer} from '../style_layer'; import type {Map} from '../../ui/map'; import {mat4} from 'gl-matrix'; import {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {ProjectionData} from '../../geo/projection/projection_data'; /** * Input arguments exposed by custom render function. */ -type CustomRenderMethodInput = { +export type CustomRenderMethodInput = { /** * This value represents the distance from the camera to the far clipping plane. * It is used in the calculation of the projection matrix to determine which objects are visible. @@ -19,7 +20,9 @@ type CustomRenderMethodInput = { * nearZ should be smaller than farZ. */ nearZ: number; - /** field of view of camera **/ + /** + * Vertical field of view in radians. + */ fov: number; /** * model view projection matrix @@ -33,19 +36,82 @@ type CustomRenderMethodInput = { * https://learnopengl.com/Getting-started/Coordinate-Systems */ projectionMatrix: mat4; + /** + * Data required for picking and compiling a custom shader for the current projection. + */ + shaderData: { + /** + * Name of the shader variant that should be used. + * Depends on current projection. + * Whenever the other shader properties change, this string changes as well, + * and can be used as a key with which to cache compiled shaders. + */ + variantName: string; + /** + * The prelude code to add to the vertex shader to access MapLibre's `projectTile` projection function. + * Depends on current projection. + * @example + * ``` + * const vertexSource = `#version 300 es + * ${shaderData.vertexShaderPrelude} + * ${shaderData.define} + * in vec2 a_pos; + * void main() { + * gl_Position = projectTile(a_pos); + * }`; + * ``` + */ + vertexShaderPrelude: string; + /** + * Defines to add to the shader code. + * Depends on current projection. + * @example + * ``` + * const vertexSource = `#version 300 es + * ${shaderData.vertexShaderPrelude} + * ${shaderData.define} + * in vec2 a_pos; + * void main() { + * gl_Position = projectTile(a_pos); + * #ifdef GLOBE + * // Do globe-specific things + * #endif + * }`; + * ``` + */ + define: string; + }; + /** + * Uniforms that should be passed to the vertex shader, if MapLibre's projection code is used. + * For more details of this object's internals, see its doc comments in `src/geo/projection/projection_data.ts`. + * + * These uniforms are set so that `projectTile` in shader accepts a vec2 in range 0..1 in web mercator coordinates. + * Use `map.transform.getProjectionData(tileID)` to get uniforms for a given tile and pass vec2 in tile-local range 0..EXTENT instead. + * + * For projection 3D features, use `projectTileFor3D` in the shader. + * + * If you just need a projection matrix, use `defaultProjectionData.projectionMatrix`. + * A projection matrix is sufficient for simple custom layers that also only support mercator projection. + * + * Under mercator projection, when these uniforms are used, the shader's `projectTile` function projects spherical mercator + * coordinates to gl clip space coordinates. The spherical mercator coordinate `[0, 0]` represents the + * top left corner of the mercator world and `[1, 1]` represents the bottom right corner. When + * the `renderingMode` is `"3d"`, the z coordinate is conformal. A box with identical x, y, and z + * lengths in mercator units would be rendered as a cube. {@link MercatorCoordinate.fromLngLat} + * can be used to project a `LngLat` to a mercator coordinate. + * + * Under globe projection, when these uniforms are used, the `elevation` parameter + * passed to `projectTileFor3D` in the shader is elevation in meters above "sea level", + * or more accurately for globe, elevation above the surface of the perfect sphere used to render the planet. + */ + defaultProjectionData: ProjectionData; } /** * @param gl - The map's gl context. - * @param matrix - The map's camera matrix. It projects spherical mercator - * coordinates to gl clip space coordinates. The spherical mercator coordinate `[0, 0]` represents the - * top left corner of the mercator world and `[1, 1]` represents the bottom right corner. When - * the `renderingMode` is `"3d"`, the z coordinate is conformal. A box with identical x, y, and z - * lengths in mercator units would be rendered as a cube. {@link MercatorCoordinate.fromLngLat} - * can be used to project a `LngLat` to a mercator coordinate. - * @param options - Argument object with additional render inputs like camera properties. + * @param options - Argument object with render inputs like camera properties. */ -type CustomRenderMethod = (gl: WebGLRenderingContext|WebGL2RenderingContext, matrix: mat4, options: CustomRenderMethodInput) => void; +type CustomRenderMethod = (gl: WebGLRenderingContext|WebGL2RenderingContext, options: CustomRenderMethodInput) => void; /** * Interface for custom style layers. This is a specification for diff --git a/src/style/style_layer/fill_extrusion_style_layer.ts b/src/style/style_layer/fill_extrusion_style_layer.ts index 41b7712813..aaad255331 100644 --- a/src/style/style_layer/fill_extrusion_style_layer.ts +++ b/src/style/style_layer/fill_extrusion_style_layer.ts @@ -10,7 +10,7 @@ import Point from '@mapbox/point-geometry'; import type {FeatureState, LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {BucketParameters} from '../../data/bucket'; import type {FillExtrusionPaintProps} from './fill_extrusion_style_layer_properties.g'; -import type {Transform} from '../../geo/transform'; +import type {IReadonlyTransform} from '../../geo/transform_interface'; import type {VectorTileFeature} from '@mapbox/vector-tile'; export class Point3D extends Point { @@ -44,7 +44,7 @@ export class FillExtrusionStyleLayer extends StyleLayer { featureState: FeatureState, geometry: Array>, zoom: number, - transform: Transform, + transform: IReadonlyTransform, pixelsToTileUnits: number, pixelPosMatrix: mat4 ): boolean | number { @@ -213,7 +213,7 @@ function projectExtrusion(geometry: Array>, zBase: number, zTop: nu return [projectedBase, projectedTop]; } -function projectQueryGeometry(queryGeometry: Array, pixelPosMatrix: mat4, transform: Transform, z: number) { +function projectQueryGeometry(queryGeometry: Array, pixelPosMatrix: mat4, transform: IReadonlyTransform, z: number) { const projectedQueryGeometry = []; for (const p of queryGeometry) { const v = [p.x, p.y, z, 1] as vec4; diff --git a/src/style/style_layer/fill_style_layer.ts b/src/style/style_layer/fill_style_layer.ts index 0f42343325..b07cc7011d 100644 --- a/src/style/style_layer/fill_style_layer.ts +++ b/src/style/style_layer/fill_style_layer.ts @@ -11,7 +11,7 @@ import type {BucketParameters} from '../../data/bucket'; import type Point from '@mapbox/point-geometry'; import type {FillLayoutProps, FillPaintProps} from './fill_style_layer_properties.g'; import type {EvaluationParameters} from '../evaluation_parameters'; -import type {Transform} from '../../geo/transform'; +import type {IReadonlyTransform} from '../../geo/transform_interface'; import type {VectorTileFeature} from '@mapbox/vector-tile'; export class FillStyleLayer extends StyleLayer { @@ -49,7 +49,7 @@ export class FillStyleLayer extends StyleLayer { featureState: FeatureState, geometry: Array>, zoom: number, - transform: Transform, + transform: IReadonlyTransform, pixelsToTileUnits: number ): boolean { const translatedPolygon = translate(queryGeometry, diff --git a/src/style/style_layer/line_style_layer.ts b/src/style/style_layer/line_style_layer.ts index d13c77b821..a993b964a9 100644 --- a/src/style/style_layer/line_style_layer.ts +++ b/src/style/style_layer/line_style_layer.ts @@ -13,7 +13,7 @@ import {isZoomExpression, Step} from '@maplibre/maplibre-gl-style-spec'; import type {FeatureState, LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {Bucket, BucketParameters} from '../../data/bucket'; import type {LineLayoutProps, LinePaintProps} from './line_style_layer_properties.g'; -import type {Transform} from '../../geo/transform'; +import type {IReadonlyTransform} from '../../geo/transform_interface'; import type {VectorTileFeature} from '@mapbox/vector-tile'; export class LineFloorwidthProperty extends DataDrivenProperty { @@ -99,7 +99,7 @@ export class LineStyleLayer extends StyleLayer { featureState: FeatureState, geometry: Array>, zoom: number, - transform: Transform, + transform: IReadonlyTransform, pixelsToTileUnits: number ): boolean { const translatedPolygon = translate(queryGeometry, diff --git a/src/symbol/collision_index.test.ts b/src/symbol/collision_index.test.ts index 7f9ffff5db..e684da8f13 100644 --- a/src/symbol/collision_index.test.ts +++ b/src/symbol/collision_index.test.ts @@ -1,19 +1,17 @@ import {CollisionIndex} from './collision_index'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; +import {CanonicalTileID, UnwrappedTileID} from '../source/tile_id'; import {mat4} from 'gl-matrix'; -import {Transform} from '../geo/transform'; -import {createProjection} from '../geo/projection/projection'; - describe('CollisionIndex', () => { - test('floating point precision', () => { - const posMatrix = mat4.create(); const x = 100000.123456, y = 0; - const transform = new Transform(0, 22, 0, 60, true); + const transform = new MercatorTransform(0, 22, 0, 60, true); transform.resize(200, 200); + const tile = new UnwrappedTileID(0, new CanonicalTileID(0, 0, 0)); + jest.spyOn(transform, 'calculatePosMatrix').mockImplementation(() => mat4.create()); - const ci = new CollisionIndex(transform, createProjection()); - expect(ci.projectAndGetPerspectiveRatio(posMatrix, x, y, null).point.x).toBeCloseTo(10000212.3456, 10); + const ci = new CollisionIndex(transform); + expect(ci.projectAndGetPerspectiveRatio(x, y, tile, null).x).toBeCloseTo(10000212.3456, 10); }); - }); diff --git a/src/symbol/collision_index.ts b/src/symbol/collision_index.ts index 00b2472a53..e4aece7f90 100644 --- a/src/symbol/collision_index.ts +++ b/src/symbol/collision_index.ts @@ -7,9 +7,7 @@ import {GridIndex} from './grid_index'; import {mat4, vec4} from 'gl-matrix'; import ONE_EM from '../symbol/one_em'; -import * as projection from '../symbol/projection'; - -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {SingleCollisionBox} from '../data/bucket/symbol_bucket'; import type { GlyphOffsetArray, @@ -17,8 +15,7 @@ import type { } from '../data/array_types.g'; import type {OverlapMode} from '../style/style_layer/overlap_mode'; import {UnwrappedTileID} from '../source/tile_id'; -import {SymbolProjectionContext} from '../symbol/projection'; -import {Projection} from '../geo/projection/projection'; +import {type PointProjection, SymbolProjectionContext, pathSlicedToLongestUnoccluded, placeFirstAndLastGlyph, projectPathSpecialProjection, xyTransformMat4} from '../symbol/projection'; import {clamp, getAABB} from '../util/util'; // When a symbol crosses the edge that causes it to be included in @@ -70,30 +67,27 @@ type ProjectedBox = { export class CollisionIndex { grid: GridIndex; ignoredGrid: GridIndex; - transform: Transform; + transform: IReadonlyTransform; pitchFactor: number; screenRightBoundary: number; screenBottomBoundary: number; gridRightBoundary: number; gridBottomBoundary: number; - mapProjection: Projection; // With perspectiveRatio the fontsize is calculated for tilted maps (near = bigger, far = smaller). // The cutoff defines a threshold to no longer render labels near the horizon. perspectiveRatioCutoff: number; constructor( - transform: Transform, - projection: Projection, + transform: IReadonlyTransform, grid = new GridIndex(transform.width + 2 * viewportPadding, transform.height + 2 * viewportPadding, 25), ignoredGrid = new GridIndex(transform.width + 2 * viewportPadding, transform.height + 2 * viewportPadding, 25) ) { this.transform = transform; - this.mapProjection = projection; this.grid = grid; this.ignoredGrid = ignoredGrid; - this.pitchFactor = Math.cos(transform._pitch) * transform.cameraToCenterDistance; + this.pitchFactor = Math.cos(transform.pitch * Math.PI / 180.0) * transform.cameraToCenterDistance; this.screenRightBoundary = transform.width + viewportPadding; this.screenBottomBoundary = transform.height + viewportPadding; @@ -107,33 +101,32 @@ export class CollisionIndex { collisionBox: SingleCollisionBox, overlapMode: OverlapMode, textPixelRatio: number, - posMatrix: mat4, unwrappedTileID: UnwrappedTileID, pitchWithMap: boolean, rotateWithMap: boolean, translation: [number, number], collisionGroupPredicate?: (key: FeatureKey) => boolean, getElevation?: (x: number, y: number) => number, - shift?: Point + shift?: Point, + simpleProjectionMatrix?: mat4, ): PlacedBox { const x = collisionBox.anchorPointX + translation[0]; const y = collisionBox.anchorPointY + translation[1]; const projectedPoint = this.projectAndGetPerspectiveRatio( - posMatrix, x, y, unwrappedTileID, - getElevation + getElevation, + simpleProjectionMatrix ); const tileToViewport = textPixelRatio * projectedPoint.perspectiveRatio; - let projectedBox: ProjectedBox; if (!pitchWithMap && !rotateWithMap) { // Fast path for common symbols - const pointX = projectedPoint.point.x + (shift ? shift.x * tileToViewport : 0); - const pointY = projectedPoint.point.y + (shift ? shift.y * tileToViewport : 0); + const pointX = projectedPoint.x + (shift ? shift.x * tileToViewport : 0); + const pointY = projectedPoint.y + (shift ? shift.y * tileToViewport : 0); projectedBox = { allPointsOccluded: false, box: [ @@ -147,22 +140,30 @@ export class CollisionIndex { projectedBox = this._projectCollisionBox( collisionBox, tileToViewport, - posMatrix, unwrappedTileID, pitchWithMap, rotateWithMap, translation, projectedPoint, getElevation, - shift + shift, + simpleProjectionMatrix, ); } const [tlX, tlY, brX, brY] = projectedBox.box; - const projectionOccluded = this.mapProjection.useSpecialProjectionForSymbols ? (pitchWithMap ? projectedBox.allPointsOccluded : this.mapProjection.isOccluded(x, y, unwrappedTileID)) : false; + // Conditions are ordered from the fastest to evaluate to the slowest. + let unplaceable = false; + if (pitchWithMap) { + unplaceable ||= projectedBox.allPointsOccluded; + } else { + unplaceable ||= projectedPoint.isOccluded; + } + unplaceable ||= projectedPoint.perspectiveRatio < this.perspectiveRatioCutoff; + unplaceable ||= !this.isInsideGrid(tlX, tlY, brX, brY); - if (projectionOccluded || projectedPoint.perspectiveRatio < this.perspectiveRatioCutoff || !this.isInsideGrid(tlX, tlY, brX, brY) || + if (unplaceable || (overlapMode !== 'always' && this.grid.hitTest(tlX, tlY, brX, brY, overlapMode, collisionGroupPredicate))) { return { box: [tlX, tlY, brX, brY], @@ -184,10 +185,8 @@ export class CollisionIndex { lineVertexArray: SymbolLineVertexArray, glyphOffsetArray: GlyphOffsetArray, fontSize: number, - posMatrix: mat4, unwrappedTileID: UnwrappedTileID, - labelPlaneMatrix: mat4, - labelToScreenMatrix: mat4, + pitchedLabelPlaneMatrix: mat4, showCollisionCircles: boolean, pitchWithMap: boolean, collisionGroupPredicate: (key: FeatureKey) => boolean, @@ -199,7 +198,7 @@ export class CollisionIndex { const placedCollisionCircles = []; const tileUnitAnchorPoint = new Point(symbol.anchorX, symbol.anchorY); - const perspectiveRatio = this.getPerspectiveRatio(posMatrix, tileUnitAnchorPoint.x, tileUnitAnchorPoint.y, unwrappedTileID, getElevation); + const perspectiveRatio = this.getPerspectiveRatio(tileUnitAnchorPoint.x, tileUnitAnchorPoint.y, unwrappedTileID, getElevation); const labelPlaneFontSize = pitchWithMap ? fontSize / perspectiveRatio : fontSize * perspectiveRatio; const labelPlaneFontScale = labelPlaneFontSize / ONE_EM; @@ -209,11 +208,11 @@ export class CollisionIndex { const projectionContext: SymbolProjectionContext = { getElevation, - labelPlaneMatrix, + pitchedLabelPlaneMatrix, lineVertexArray, pitchWithMap, projectionCache, - projection: this.mapProjection, + transform: this.transform, tileAnchorPoint: tileUnitAnchorPoint, unwrappedTileID, width: this.transform.width, @@ -221,7 +220,7 @@ export class CollisionIndex { translation }; - const firstAndLastGlyph = projection.placeFirstAndLastGlyph( + const firstAndLastGlyph = placeFirstAndLastGlyph( labelPlaneFontScale, glyphOffsetArray, lineOffsetX, @@ -257,8 +256,8 @@ export class CollisionIndex { const circleDist = radius * 2.5; // The path might need to be converted into screen space if a pitched map is used as the label space - if (labelToScreenMatrix) { - const screenSpacePath = this.projectPathToScreenSpace(projectedPath, projectionContext, labelToScreenMatrix); + if (pitchWithMap) { + const screenSpacePath = this.projectPathToScreenSpace(projectedPath, projectionContext); // Do not try to place collision circles if even one of the points is behind the camera. // This is a plausible scenario with big camera pitch angles if (screenSpacePath.some(point => point.signedDistanceFromCamera <= 0)) { @@ -349,8 +348,12 @@ export class CollisionIndex { }; } - projectPathToScreenSpace(projectedPath: Array, projectionContext: SymbolProjectionContext, labelToScreenMatrix: mat4) { - return projectedPath.map(p => projection.project(p.x, p.y, labelToScreenMatrix, projectionContext.getElevation)); + projectPathToScreenSpace(projectedPath: Array, projectionContext: SymbolProjectionContext): Array { + const screenSpacePath = projectPathSpecialProjection(projectedPath, projectionContext); + // We don't want to generate screenspace collision circles for parts of the line that + // are occluded by the planet itself. Find the longest segment of the path that is + // not occluded, and remove everything else. + return pathSlicedToLongestUnoccluded(screenSpacePath); } /** @@ -434,37 +437,45 @@ export class CollisionIndex { } } - projectAndGetPerspectiveRatio(posMatrix: mat4, x: number, y: number, _unwrappedTileID: UnwrappedTileID, getElevation?: (x: number, y: number) => number) { - // The code here is duplicated from "projection.ts" for performance. - // Code here is subject to change once globe is merged. - let pos; - if (getElevation) { // slow because of handle z-index - pos = [x, y, getElevation(x, y), 1] as vec4; - vec4.transformMat4(pos, pos, posMatrix); - } else { // fast because of ignore z-index - pos = [x, y, 0, 1] as vec4; - projection.xyTransformMat4(pos, pos, posMatrix); + projectAndGetPerspectiveRatio(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation?: (x: number, y: number) => number, simpleProjectionMatrix?: mat4) { + if (simpleProjectionMatrix) { + // This branch is a fast-path for mercator transform. + // The code here is a copy of MercatorTransform.projectTileCoordinates, slightly modified for extra performance. + // This has a huge impact for some reason. + let pos; + if (getElevation) { // slow because of handle z-index + pos = [x, y, getElevation(x, y), 1] as vec4; + vec4.transformMat4(pos, pos, simpleProjectionMatrix); + } else { // fast because of ignore z-index + pos = [x, y, 0, 1] as vec4; + xyTransformMat4(pos, pos, simpleProjectionMatrix); + } + const w = pos[3]; + return { + x: (((pos[0] / w + 1) / 2) * this.transform.width) + viewportPadding, + y: (((-pos[1] / w + 1) / 2) * this.transform.height) + viewportPadding, + perspectiveRatio: 0.5 + 0.5 * (this.transform.cameraToCenterDistance / w), + isOccluded: false, + signedDistanceFromCamera: w + }; + } else { + const projected = this.transform.projectTileCoordinates(x, y, unwrappedTileID, getElevation); + return { + x: (((projected.point.x + 1) / 2) * this.transform.width) + viewportPadding, + y: (((-projected.point.y + 1) / 2) * this.transform.height) + viewportPadding, + // See perspective ratio comment in symbol_sdf.vertex + // We're doing collision detection in viewport space so we need + // to scale down boxes in the distance + perspectiveRatio: 0.5 + 0.5 * (this.transform.cameraToCenterDistance / projected.signedDistanceFromCamera), + isOccluded: projected.isOccluded, + signedDistanceFromCamera: projected.signedDistanceFromCamera + }; } - const w = pos[3]; - return { - point: new Point( - (((pos[0] / w + 1) / 2) * this.transform.width) + viewportPadding, - (((-pos[1] / w + 1) / 2) * this.transform.height) + viewportPadding - ), - // See perspective ratio comment in symbol_sdf.vertex - // We're doing collision detection in viewport space so we need - // to scale down boxes in the distance - perspectiveRatio: 0.5 + 0.5 * (this.transform.cameraToCenterDistance / w), - isOccluded: false, - signedDistanceFromCamera: w - }; } - getPerspectiveRatio(posMatrix: mat4, x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation?: (x: number, y: number) => number): number { + getPerspectiveRatio(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation?: (x: number, y: number) => number): number { // We don't care about the actual projected point, just its W component. - const projected = this.mapProjection.useSpecialProjectionForSymbols ? - this.mapProjection.projectTileCoordinates(x, y, unwrappedTileID, getElevation) : - projection.project(x, y, posMatrix, getElevation); + const projected = this.transform.projectTileCoordinates(x, y, unwrappedTileID, getElevation); return 0.5 + 0.5 * (this.transform.cameraToCenterDistance / projected.signedDistanceFromCamera); } @@ -493,57 +504,66 @@ export class CollisionIndex { private _projectCollisionBox( collisionBox: SingleCollisionBox, tileToViewport: number, - posMatrix: mat4, unwrappedTileID: UnwrappedTileID, pitchWithMap: boolean, rotateWithMap: boolean, translation: [number, number], - projectedPoint: {point: Point; perspectiveRatio: number; signedDistanceFromCamera: number}, + projectedPoint: {x: number; y: number; perspectiveRatio: number; signedDistanceFromCamera: number}, getElevation?: (x: number, y: number) => number, - shift?: Point + shift?: Point, + simpleProjectionMatrix?: mat4, ): ProjectedBox { - // These vectors are valid both for screen space viewport-rotation-aligned texts and for pitch-align: map texts that are map-rotation-aligned. - let vecEast = new Point(1, 0); - let vecSouth = new Point(0, 1); + let vecEastX = 1; + let vecEastY = 0; + let vecSouthX = 0; + let vecSouthY = 1; - const translatedAnchor = new Point(collisionBox.anchorPointX + translation[0], collisionBox.anchorPointY + translation[1]); + const translatedAnchorX = collisionBox.anchorPointX + translation[0]; + const translatedAnchorY = collisionBox.anchorPointY + translation[1]; if (rotateWithMap && !pitchWithMap) { // Handles screen space texts that are always aligned east-west. const projectedEast = this.projectAndGetPerspectiveRatio( - posMatrix, - translatedAnchor.x + 1, - translatedAnchor.y, + translatedAnchorX + 1, + translatedAnchorY, unwrappedTileID, - getElevation - ).point; - const toEast = projectedEast.sub(projectedPoint.point).unit(); - const angle = Math.atan(toEast.y / toEast.x) + (toEast.x < 0 ? Math.PI : 0); + getElevation, + simpleProjectionMatrix, + ); + const toEastX = projectedEast.x - projectedPoint.x; + const toEastY = projectedEast.y - projectedPoint.y; + const angle = Math.atan(toEastY / toEastX) + (toEastX < 0 ? Math.PI : 0); const sin = Math.sin(angle); const cos = Math.cos(angle); - vecEast = new Point(cos, sin); - vecSouth = new Point(-sin, cos); + vecEastX = cos; + vecEastY = sin; + vecSouthX = -sin; + vecSouthY = cos; } else if (!rotateWithMap && pitchWithMap) { // Handles pitch-align: map texts that are always aligned with the viewport's X axis. const angle = -this.transform.angle; const sin = Math.sin(angle); const cos = Math.cos(angle); - vecEast = new Point(cos, sin); - vecSouth = new Point(-sin, cos); + vecEastX = cos; + vecEastY = sin; + vecSouthX = -sin; + vecSouthY = cos; } // Configuration for screen space offsets - let basePoint = projectedPoint.point; + let basePointX = projectedPoint.x; + let basePointY = projectedPoint.y; let distanceMultiplier = tileToViewport; if (pitchWithMap) { // Configuration for tile space (map-pitch-aligned) offsets - basePoint = translatedAnchor; + basePointX = translatedAnchorX; + basePointY = translatedAnchorY; const zoomFraction = this.transform.zoom - Math.floor(this.transform.zoom); distanceMultiplier = Math.pow(2, -zoomFraction); - distanceMultiplier *= this.mapProjection.getPitchedTextCorrection(this.transform, translatedAnchor, unwrappedTileID); + distanceMultiplier *= this.transform.getPitchedTextCorrection(translatedAnchorX, translatedAnchorY, unwrappedTileID); // This next correction can't be applied when variable anchors are in use. if (!shift) { @@ -560,7 +580,8 @@ export class CollisionIndex { if (shift) { // Variable anchors are in use - basePoint = basePoint.add(vecEast.mult(shift.x * distanceMultiplier)).add(vecSouth.mult(shift.y * distanceMultiplier)); + basePointX += vecEastX * shift.x * distanceMultiplier + vecSouthX * shift.y * distanceMultiplier; + basePointY += vecEastY * shift.x * distanceMultiplier + vecSouthY * shift.y * distanceMultiplier; } const offsetXmin = collisionBox.x1 * distanceMultiplier; @@ -590,8 +611,8 @@ export class CollisionIndex { for (const {offsetX, offsetY} of offsetsArray) { points.push(new Point( - basePoint.x + vecEast.x * offsetX + vecSouth.x * offsetY, - basePoint.y + vecEast.y * offsetX + vecSouth.y * offsetY + basePointX + vecEastX * offsetX + vecSouthX * offsetY, + basePointY + vecEastY * offsetX + vecSouthY * offsetY )); } @@ -599,12 +620,12 @@ export class CollisionIndex { let anyPointVisible = false; if (pitchWithMap) { - const projected = points.map(p => this.projectAndGetPerspectiveRatio(posMatrix, p.x, p.y, unwrappedTileID, getElevation)); + const projected = points.map(p => this.projectAndGetPerspectiveRatio(p.x, p.y, unwrappedTileID, getElevation, simpleProjectionMatrix)); // Is at least one of the projected points NOT behind the horizon? anyPointVisible = projected.some(p => !p.isOccluded); - points = projected.map(p => p.point); + points = projected.map(p => new Point(p.x, p.y)); } else { // Labels that are not pitchWithMap cannot ever hide behind the horizon. anyPointVisible = true; diff --git a/src/symbol/placement.ts b/src/symbol/placement.ts index 212adcf6ff..feba0edebb 100644 --- a/src/symbol/placement.ts +++ b/src/symbol/placement.ts @@ -8,7 +8,7 @@ import {getAnchorAlignment, WritingMode} from './shaping'; import {mat4} from 'gl-matrix'; import {pixelsToTileUnits} from '../source/pixels_to_tile_units'; import Point from '@mapbox/point-geometry'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform, ITransform} from '../geo/transform_interface'; import type {StyleLayer} from '../style/style_layer'; import {PossiblyEvaluated} from '../style/properties'; import type {SymbolLayoutProps, SymbolLayoutPropsPossiblyEvaluated} from '../style/style_layer/symbol_style_layer_properties.g'; @@ -21,9 +21,8 @@ import type {CollisionBoxArray, CollisionVertexArray, SymbolInstance, TextAnchor import type {FeatureIndex} from '../data/feature_index'; import type {OverscaledTileID, UnwrappedTileID} from '../source/tile_id'; import {Terrain} from '../render/terrain'; -import {warnOnce} from '../util/util'; +import {translatePosition, warnOnce} from '../util/util'; import {TextAnchor, TextAnchorEnum} from '../style/style_layer/variable_text_anchor'; -import {Projection} from '../geo/projection/projection'; class OpacityState { opacity: number; @@ -68,19 +67,6 @@ class JointPlacement { } } -class CollisionCircleArray { - // Stores collision circles and placement matrices of a bucket for debug rendering. - invProjMatrix: mat4; - viewportMatrix: mat4; - circles: Array; - - constructor() { - this.invProjMatrix = mat4.create(); - this.viewportMatrix = mat4.create(); - this.circles = []; - } -} - export class RetainedQueryData { bucketInstanceId: number; featureIndex: FeatureIndex; @@ -169,9 +155,7 @@ type TileLayerParameters = { translationText: [number, number]; translationIcon: [number, number]; unwrappedTileID: UnwrappedTileID; - posMatrix: mat4; - textLabelPlaneMatrix: mat4; - labelToScreenMatrix: mat4; + pitchedLabelPlaneMatrix: mat4; scale: number; textPixelRatio: number; holdingForFade: boolean; @@ -193,7 +177,7 @@ export type BucketPart = { export type CrossTileID = string | number; export class Placement { - transform: Transform; + transform: IReadonlyTransform; terrain: Terrain; collisionIndex: CollisionIndex; placements: { @@ -220,17 +204,17 @@ export class Placement { prevPlacement: Placement; zoomAtLastRecencyCheck: number; collisionCircleArrays: { - [k in any]: CollisionCircleArray; + [k in any]: Array; }; collisionBoxArrays: Map>; - constructor(transform: Transform, projection: Projection, terrain: Terrain, fadeDuration: number, crossSourceCollisions: boolean, prevPlacement?: Placement) { + constructor(transform: ITransform, terrain: Terrain, fadeDuration: number, crossSourceCollisions: boolean, prevPlacement?: Placement) { this.transform = transform.clone(); this.terrain = terrain; - this.collisionIndex = new CollisionIndex(this.transform, projection); + this.collisionIndex = new CollisionIndex(this.transform); this.placements = {}; this.opacities = {}; this.variableOffsets = {}; @@ -274,42 +258,22 @@ export class Placement { const unwrappedTileID = tile.tileID.toUnwrapped(); - const posMatrix = this.transform.calculatePosMatrix(unwrappedTileID); - - const pitchWithMap = layout.get('text-pitch-alignment') === 'map'; const rotateWithMap = layout.get('text-rotation-alignment') === 'map'; const pixelsToTiles = pixelsToTileUnits(tile, 1, this.transform.zoom); - const translationText = this.collisionIndex.mapProjection.translatePosition( - this.transform, + const translationText = translatePosition( + this.collisionIndex.transform, tile, paint.get('text-translate'), paint.get('text-translate-anchor'),); - const translationIcon = this.collisionIndex.mapProjection.translatePosition( - this.transform, + const translationIcon = translatePosition( + this.collisionIndex.transform, tile, paint.get('icon-translate'), paint.get('icon-translate-anchor'),); - const textLabelPlaneMatrix = projection.getLabelPlaneMatrix(posMatrix, - pitchWithMap, - rotateWithMap, - this.transform, - pixelsToTiles); - - let labelToScreenMatrix = null; - - if (pitchWithMap) { - const glMatrix = projection.getGlCoordMatrix( - posMatrix, - pitchWithMap, - rotateWithMap, - this.transform, - pixelsToTiles); - - labelToScreenMatrix = mat4.multiply([] as any, this.transform.labelPlaneMatrix, glMatrix); - } + const pitchedLabelPlaneMatrix = projection.getPitchedLabelPlaneMatrix(rotateWithMap, this.transform, pixelsToTiles); // As long as this placement lives, we have to hold onto this bucket's // matching FeatureIndex/data for querying purposes @@ -326,10 +290,8 @@ export class Placement { layout, translationText, translationIcon, - posMatrix, unwrappedTileID, - textLabelPlaneMatrix, - labelToScreenMatrix, + pitchedLabelPlaneMatrix, scale, textPixelRatio, holdingForFade: tile.holdingForFade(), @@ -361,7 +323,6 @@ export class Placement { rotateWithMap: boolean, pitchWithMap: boolean, textPixelRatio: number, - posMatrix: mat4, unwrappedTileID, collisionGroup: CollisionGroup, textOverlapMode: OverlapMode, @@ -371,7 +332,8 @@ export class Placement { translationText: [number, number], translationIcon: [number, number], iconBox?: SingleCollisionBox | null, - getElevation?: (x: number, y: number) => number + getElevation?: (x: number, y: number) => number, + simpleProjectionMatrix?: mat4, ): { shift: Point; placedGlyphBoxes: PlacedBox; @@ -385,14 +347,14 @@ export class Placement { textBox, textOverlapMode, textPixelRatio, - posMatrix, unwrappedTileID, pitchWithMap, rotateWithMap, translationText, collisionGroup.predicate, getElevation, - shift + shift, + simpleProjectionMatrix, ); if (iconBox) { @@ -400,14 +362,14 @@ export class Placement { iconBox, textOverlapMode, textPixelRatio, - posMatrix, unwrappedTileID, pitchWithMap, rotateWithMap, translationIcon, collisionGroup.predicate, getElevation, - shift + shift, + simpleProjectionMatrix, ); if (!placedIconBoxes.placeable) return; } @@ -451,10 +413,8 @@ export class Placement { layout, translationText, translationIcon, - posMatrix, unwrappedTileID, - textLabelPlaneMatrix, - labelToScreenMatrix, + pitchedLabelPlaneMatrix, textPixelRatio, holdingForFade, collisionBoxArray, @@ -496,6 +456,7 @@ export class Placement { const tileID = this.retainedQueryData[bucket.bucketInstanceId].tileID; const getElevation = this._getTerrainElevationFunc(tileID); + const simpleProjectionMatrix = this.transform.getFastPathSimpleProjectionMatrix(tileID); const placeSymbol = (symbolInstance: SymbolInstance, collisionArrays: CollisionArrays, symbolIndex: number) => { if (seenCrossTileIDs[symbolInstance.crossTileID]) return; @@ -572,13 +533,14 @@ export class Placement { collisionTextBox, textOverlapMode, textPixelRatio, - posMatrix, unwrappedTileID, pitchWithMap, rotateWithMap, translationText, collisionGroup.predicate, - getElevation + getElevation, + undefined, + simpleProjectionMatrix, ); if (placedFeature && placedFeature.placeable) { this.markUsedOrientation(bucket, orientation, symbolInstance); @@ -630,7 +592,7 @@ export class Placement { const result = this.attemptAnchorPlacement( textAnchorOffset, collisionTextBox, width, height, - textBoxScale, rotateWithMap, pitchWithMap, textPixelRatio, posMatrix, unwrappedTileID, + textBoxScale, rotateWithMap, pitchWithMap, textPixelRatio, unwrappedTileID, collisionGroup, overlapMode, symbolInstance, bucket, orientation, translationText, translationIcon, variableIconBox, getElevation); if (result) { @@ -657,14 +619,14 @@ export class Placement { textBox, 'always', // Skips expensive collision check with already placed boxes textPixelRatio, - posMatrix, unwrappedTileID, pitchWithMap, rotateWithMap, translationText, collisionGroup.predicate, getElevation, - new Point(0, 0) + undefined, + simpleProjectionMatrix, ); placedBox = { box: placedFakeGlyphBox.box, @@ -729,10 +691,8 @@ export class Placement { bucket.lineVertexArray, bucket.glyphOffsetArray, fontSize, - posMatrix, unwrappedTileID, - textLabelPlaneMatrix, - labelToScreenMatrix, + pitchedLabelPlaneMatrix, showCollisionBoxes, pitchWithMap, collisionGroup.predicate, @@ -764,7 +724,6 @@ export class Placement { iconBox, iconOverlapMode, textPixelRatio, - posMatrix, unwrappedTileID, pitchWithMap, rotateWithMap, @@ -772,6 +731,7 @@ export class Placement { collisionGroup.predicate, getElevation, (hasIconTextFit && shift) ? shift : undefined, + simpleProjectionMatrix, ); }; @@ -866,14 +826,6 @@ export class Placement { } } - if (showCollisionBoxes && bucket.bucketInstanceId in this.collisionCircleArrays) { - const circleArray = this.collisionCircleArrays[bucket.bucketInstanceId]; - - // Store viewport and inverse projection matrices per bucket - mat4.invert(circleArray.invProjMatrix, posMatrix); - circleArray.viewportMatrix = this.collisionIndex.getViewportMatrix(); - } - bucket.justReloaded = false; } @@ -923,13 +875,13 @@ export class Placement { // Group collision circles together by bucket. Circles can't be pushed forward for rendering yet as the symbol placement // for a bucket is not guaranteed to be complete before the commit-function has been called if (circleArray === undefined) - circleArray = this.collisionCircleArrays[bucketInstanceId] = new CollisionCircleArray(); + circleArray = this.collisionCircleArrays[bucketInstanceId] = []; for (let i = 0; i < placedGlyphCircles.circles.length; i += 4) { - circleArray.circles.push(placedGlyphCircles.circles[i + 0]); // x - circleArray.circles.push(placedGlyphCircles.circles[i + 1]); // y - circleArray.circles.push(placedGlyphCircles.circles[i + 2]); // radius - circleArray.circles.push(placedGlyphCircles.collisionDetected ? 1 : 0); // collisionDetected-flag + circleArray.push(placedGlyphCircles.circles[i + 0] - viewportPadding); // x + circleArray.push(placedGlyphCircles.circles[i + 1] - viewportPadding); // y + circleArray.push(placedGlyphCircles.circles[i + 2]); // radius + circleArray.push(placedGlyphCircles.collisionDetected ? 1 : 0); // collisionDetected-flag } } } @@ -1281,12 +1233,7 @@ export class Placement { // Push generated collision circles to the bucket for debug rendering if (bucket.bucketInstanceId in this.collisionCircleArrays) { - const instance = this.collisionCircleArrays[bucket.bucketInstanceId]; - - bucket.placementInvProjMatrix = instance.invProjMatrix; - bucket.placementViewportMatrix = instance.viewportMatrix; - bucket.collisionCircleArray = instance.circles; - + bucket.collisionCircleArray = this.collisionCircleArrays[bucket.bucketInstanceId]; delete this.collisionCircleArrays[bucket.bucketInstanceId]; } } diff --git a/src/symbol/projection.test.ts b/src/symbol/projection.test.ts index 94d8ad045d..c0f1674819 100644 --- a/src/symbol/projection.test.ts +++ b/src/symbol/projection.test.ts @@ -1,14 +1,15 @@ -import {SymbolProjectionContext, ProjectionSyntheticVertexArgs, findOffsetIntersectionPoint, project, projectVertexToViewport, transformToOffsetNormal} from './projection'; +import {SymbolProjectionContext, ProjectionSyntheticVertexArgs, findOffsetIntersectionPoint, projectWithMatrix, transformToOffsetNormal, projectLineVertexToLabelPlane} from './projection'; import Point from '@mapbox/point-geometry'; import {mat4} from 'gl-matrix'; import {SymbolLineVertexArray} from '../data/array_types.g'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; describe('Projection', () => { test('matrix float precision', () => { const point = new Point(10.000000005, 0); const matrix = mat4.create(); - expect(project(point.x, point.y, matrix).point.x).toBeCloseTo(point.x, 10); + expect(projectWithMatrix(point.x, point.y, matrix).point.x).toBeCloseTo(point.x, 10); }); }); @@ -18,18 +19,19 @@ describe('Vertex to viewport projection', () => { lineVertexArray.emplaceBack(-10, 0, -10); lineVertexArray.emplaceBack(0, 0, 0); lineVertexArray.emplaceBack(10, 0, 10); + const transform = new MercatorTransform(); test('projecting with null matrix', () => { const projectionContext: SymbolProjectionContext = { projectionCache: {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false}, lineVertexArray, - labelPlaneMatrix: mat4.create(), + pitchedLabelPlaneMatrix: mat4.create(), getElevation: (_x, _y) => 0, // Only relevant in "behind the camera" case, can't happen with null projection matrix tileAnchorPoint: new Point(0, 0), pitchWithMap: true, - projection: null, unwrappedTileID: null, + transform, width: 1, height: 1, translation: [0, 0] @@ -42,9 +44,9 @@ describe('Vertex to viewport projection', () => { absOffsetX: 0 }; - const first = projectVertexToViewport(0, projectionContext, syntheticVertexArgs); - const second = projectVertexToViewport(1, projectionContext, syntheticVertexArgs); - const third = projectVertexToViewport(2, projectionContext, syntheticVertexArgs); + const first = projectLineVertexToLabelPlane(0, projectionContext, syntheticVertexArgs); + const second = projectLineVertexToLabelPlane(1, projectionContext, syntheticVertexArgs); + const third = projectLineVertexToLabelPlane(2, projectionContext, syntheticVertexArgs); expect(first.x).toBeCloseTo(-10); expect(second.x).toBeCloseTo(0); expect(third.x).toBeCloseTo(10); @@ -62,15 +64,16 @@ describe('Find offset line intersections', () => { lineVertexArray.emplaceBack(-10, 0, -10); lineVertexArray.emplaceBack(0, 0, 0); lineVertexArray.emplaceBack(10, 0, 10); + const transform = new MercatorTransform(); const projectionContext: SymbolProjectionContext = { projectionCache: {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false}, lineVertexArray, - labelPlaneMatrix: mat4.create(), + pitchedLabelPlaneMatrix: mat4.create(), getElevation: (_x, _y) => 0, tileAnchorPoint: new Point(0, 0), + transform, pitchWithMap: true, - projection: null, unwrappedTileID: null, width: 1, height: 1, diff --git a/src/symbol/projection.ts b/src/symbol/projection.ts index ebf641766f..54a24b1d05 100644 --- a/src/symbol/projection.ts +++ b/src/symbol/projection.ts @@ -5,7 +5,7 @@ import * as symbolSize from './symbol_size'; import {addDynamicAttributes} from '../data/bucket/symbol_bucket'; import type {Painter} from '../render/painter'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; import type {SymbolBucket} from '../data/bucket/symbol_bucket'; import type { GlyphOffsetArray, @@ -15,23 +15,7 @@ import type { import {WritingMode} from '../symbol/shaping'; import {findLineIntersection} from '../util/util'; import {UnwrappedTileID} from '../source/tile_id'; -import {Projection} from '../geo/projection/projection'; - -export { - updateLineLabels, - hideGlyphs, - getLabelPlaneMatrix, - getGlCoordMatrix, - project, - getPerspectiveRatio, - placeFirstAndLastGlyph, - placeGlyphAlongLine, - xyTransformMat4, - projectLineVertexToViewport as projectVertexToViewport, - projectTileCoordinatesToViewport, - findOffsetIntersectionPoint, - transformToOffsetNormal, -}; +import {StructArray} from '../util/struct_array'; /** * The result of projecting a point to the screen, with some additional information about the projection. @@ -47,6 +31,7 @@ export type PointProjection = { signedDistanceFromCamera: number; /** * For complex projections (such as globe), true if the point is occluded by the projection, such as by being on the backfacing side of the globe. + * If the point is simply beyond the edge of the screen, this should NOT be set to false. */ isOccluded: boolean; }; @@ -95,49 +80,56 @@ export type PointProjection = { * Since the conversion is handled on the cpu we just set `u_label_plane_matrix` to an identity matrix. * * Steps 3 and 4 are done in the shaders for all labels. + * + * + * # Custom projection handling + * Note that since MapLibre now supports more than one projection, the transformation + * to viewport pixel space and GL clip space now *must* go through the projection's (`transform`'s) + * `projectTileCoordinates` function, since it might do nontrivial transformations. + * + * Hence projecting anything to a symbol's label plane can no longer be handled by a simple matrix, + * since, if the symbol's label plane is viewport pixel space, `projectTileCoordinates` must be used. + * This is applies both here and in the symbol vertex shaders. */ -/* - * Returns a matrix for converting from tile units to the correct label coordinate space. - */ -function getLabelPlaneMatrix(posMatrix: mat4, - pitchWithMap: boolean, +export function getPitchedLabelPlaneMatrix( rotateWithMap: boolean, - transform: Transform, + transform: IReadonlyTransform, pixelsToTileUnits: number) { const m = mat4.create(); - if (pitchWithMap) { - mat4.scale(m, m, [1 / pixelsToTileUnits, 1 / pixelsToTileUnits, 1]); - if (!rotateWithMap) { - mat4.rotateZ(m, m, transform.angle); - } - } else { - mat4.multiply(m, transform.labelPlaneMatrix, posMatrix); + mat4.scale(m, m, [1 / pixelsToTileUnits, 1 / pixelsToTileUnits, 1]); + if (!rotateWithMap) { + mat4.rotateZ(m, m, transform.angle); } return m; } /* - * Returns a matrix for converting from the correct label coordinate space to clip space. + * Returns a matrix for either converting from pitched label space to tile space, + * or for converting from screenspace pixels to clip space. */ -function getGlCoordMatrix(posMatrix: mat4, +export function getGlCoordMatrix( pitchWithMap: boolean, rotateWithMap: boolean, - transform: Transform, + transform: IReadonlyTransform, pixelsToTileUnits: number) { if (pitchWithMap) { - const m = mat4.clone(posMatrix); + const m = mat4.create(); mat4.scale(m, m, [pixelsToTileUnits, pixelsToTileUnits, 1]); if (!rotateWithMap) { mat4.rotateZ(m, m, -transform.angle); } return m; } else { - return transform.glCoordMatrix; + return transform.pixelsToClipSpaceMatrix; } } -function project(x: number, y: number, matrix: mat4, getElevation?: (x: number, y: number) => number): PointProjection { +/** + * Projects a point using a specified matrix, including the perspective divide. + * Uses a fast path if `getElevation` is undefined. + */ +export function projectWithMatrix(x: number, y: number, matrix: mat4, getElevation?: (x: number, y: number) => number): PointProjection { let pos; if (getElevation) { // slow because of handle z-index pos = [x, y, getElevation(x, y), 1] as vec4; @@ -154,7 +146,7 @@ function project(x: number, y: number, matrix: mat4, getElevation?: (x: number, }; } -function getPerspectiveRatio(cameraToCenterDistance: number, signedDistanceFromCamera: number): number { +export function getPerspectiveRatio(cameraToCenterDistance: number, signedDistanceFromCamera: number): number { return 0.5 + 0.5 * (cameraToCenterDistance / signedDistanceFromCamera); } @@ -172,16 +164,14 @@ function isVisible(p: Point, * Update the `dynamicLayoutVertexBuffer` for the buffer with the correct glyph positions for the current map view. * This is only run on labels that are aligned with lines. Horizontal labels are handled entirely in the shader. */ -function updateLineLabels(bucket: SymbolBucket, - posMatrix: mat4, +export function updateLineLabels(bucket: SymbolBucket, painter: Painter, isText: boolean, - labelPlaneMatrix: mat4, - glCoordMatrix: mat4, + pitchedLabelPlaneMatrix: mat4, + pitchedLabelPlaneMatrixInverse: mat4, pitchWithMap: boolean, keepUpright: boolean, rotateToLine: boolean, - projection: Projection, unwrappedTileID: UnwrappedTileID, viewportWidth: number, viewportHeight: number, @@ -218,30 +208,16 @@ function updateLineLabels(bucket: SymbolBucket, // Awkward... but we're counting on the paired "vertical" symbol coming immediately after its horizontal counterpart useVertical = false; - const anchorPos = project(symbol.anchorX, symbol.anchorY, posMatrix, getElevation); - - // Don't bother calculating the correct point for invisible labels. - if (!isVisible(anchorPos.point, clippingBuffer)) { - hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); - continue; - } - - const cameraToAnchorDistance = anchorPos.signedDistanceFromCamera; - const perspectiveRatio = getPerspectiveRatio(painter.transform.cameraToCenterDistance, cameraToAnchorDistance); - - const fontSize = symbolSize.evaluateSizeForFeature(sizeData, partiallyEvaluatedSize, symbol); - const pitchScaledFontSize = pitchWithMap ? fontSize / perspectiveRatio : fontSize * perspectiveRatio; - const tileAnchorPoint = new Point(symbol.anchorX, symbol.anchorY); const projectionCache: ProjectionCache = {projections: {}, offsets: {}, cachedAnchorPoint: undefined, anyProjectionOccluded: false}; const projectionContext: SymbolProjectionContext = { getElevation, - labelPlaneMatrix, + pitchedLabelPlaneMatrix, lineVertexArray, pitchWithMap, projectionCache, - projection, + transform: painter.transform, tileAnchorPoint, unwrappedTileID, width: viewportWidth, @@ -249,15 +225,49 @@ function updateLineLabels(bucket: SymbolBucket, translation }; - const placeUnflipped: any = placeGlyphsAlongLine(projectionContext, symbol, pitchScaledFontSize, false /*unflipped*/, keepUpright, posMatrix, glCoordMatrix, - bucket.glyphOffsetArray, dynamicLayoutVertexArray, aspectRatio, rotateToLine); + const anchorPos = projectTileCoordinatesToClipSpace(symbol.anchorX, symbol.anchorY, projectionContext); + + // Don't bother calculating the correct point for invisible labels. + if (!isVisible(anchorPos.point, clippingBuffer)) { + hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); + continue; + } + + const cameraToAnchorDistance = anchorPos.signedDistanceFromCamera; + const perspectiveRatio = getPerspectiveRatio(painter.transform.cameraToCenterDistance, cameraToAnchorDistance); + + const fontSize = symbolSize.evaluateSizeForFeature(sizeData, partiallyEvaluatedSize, symbol); + const pitchScaledFontSize = pitchWithMap ? fontSize / perspectiveRatio : fontSize * perspectiveRatio; + + const placeUnflipped = placeGlyphsAlongLine({ + projectionContext, + pitchedLabelPlaneMatrixInverse, + symbol, + fontSize: pitchScaledFontSize, + flip: false, + keepUpright, + glyphOffsetArray: bucket.glyphOffsetArray, + dynamicLayoutVertexArray, + aspectRatio, + rotateToLine, + }); useVertical = placeUnflipped.useVertical; if (placeUnflipped.notEnoughRoom || useVertical || (placeUnflipped.needsFlipping && - (placeGlyphsAlongLine(projectionContext, symbol, pitchScaledFontSize, true /*flipped*/, keepUpright, posMatrix, glCoordMatrix, - bucket.glyphOffsetArray, dynamicLayoutVertexArray, aspectRatio, rotateToLine) as any).notEnoughRoom)) { + placeGlyphsAlongLine({ + projectionContext, + pitchedLabelPlaneMatrixInverse, + symbol, + fontSize: pitchScaledFontSize, + flip: true, // flipped + keepUpright, + glyphOffsetArray: bucket.glyphOffsetArray, + dynamicLayoutVertexArray, + aspectRatio, + rotateToLine, + }).notEnoughRoom)) { hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); } } @@ -286,7 +296,7 @@ type FirstAndLastGlyphPlacement = { * * Returns null if the label can't fit on the geometry */ -function placeFirstAndLastGlyph( +export function placeFirstAndLastGlyph( fontScale: number, glyphOffsetArray: GlyphOffsetArray, lineOffsetX: number, @@ -319,7 +329,12 @@ function placeFirstAndLastGlyph( return {first: firstPlacedGlyph, last: lastPlacedGlyph}; } -function requiresOrientationChange(writingMode, firstPoint, lastPoint, aspectRatio) { +type OrientationChangeType = { + useVertical?: boolean; + needsFlipping?: boolean; +}; + +function requiresOrientationChange(writingMode, firstPoint, lastPoint, aspectRatio): OrientationChangeType { if (writingMode === WritingMode.horizontal) { // On top of choosing whether to flip, choose whether to render this version of the glyphs or the alternate // vertical glyphs. We can't just filter out vertical glyphs in the horizontal range because the horizontal @@ -340,6 +355,23 @@ function requiresOrientationChange(writingMode, firstPoint, lastPoint, aspectRat return null; } +type GlyphLinePlacementResult = OrientationChangeType & { + notEnoughRoom?: boolean; +} + +type GlyphLinePlacementArgs = { + projectionContext: SymbolProjectionContext; + pitchedLabelPlaneMatrixInverse: mat4; + symbol: any; // PlacedSymbolStruct + fontSize: number; + flip: boolean; + keepUpright: boolean; + glyphOffsetArray: GlyphOffsetArray; + dynamicLayoutVertexArray: StructArray; + aspectRatio: number; + rotateToLine: boolean; +} + /* * Place first and last glyph along the line projected to label plane, and if they fit * iterate through all the intermediate glyphs, calculating their label plane positions @@ -348,7 +380,20 @@ function requiresOrientationChange(writingMode, firstPoint, lastPoint, aspectRat * Finally, add resulting glyph position calculations to dynamicLayoutVertexArray for * upload to the GPU */ -function placeGlyphsAlongLine(projectionContext: SymbolProjectionContext, symbol, fontSize, flip, keepUpright, posMatrix, glCoordMatrix, glyphOffsetArray, dynamicLayoutVertexArray, aspectRatio, rotateToLine) { +function placeGlyphsAlongLine(args: GlyphLinePlacementArgs): GlyphLinePlacementResult { + const { + projectionContext, + pitchedLabelPlaneMatrixInverse, + symbol, + fontSize, + flip, + keepUpright, + glyphOffsetArray, + dynamicLayoutVertexArray, + aspectRatio, + rotateToLine + } = args; + const fontScale = fontSize / 24; const lineOffsetX = symbol.lineOffsetX * fontScale; const lineOffsetY = symbol.lineOffsetY * fontScale; @@ -361,12 +406,13 @@ function placeGlyphsAlongLine(projectionContext: SymbolProjectionContext, symbol // Place the first and the last glyph in the label first, so we can figure out // the overall orientation of the label and determine whether it needs to be flipped in keepUpright mode + // Note: these glyphs are placed onto the label plane const firstAndLastGlyph = placeFirstAndLastGlyph(fontScale, glyphOffsetArray, lineOffsetX, lineOffsetY, flip, symbol, rotateToLine, projectionContext); if (!firstAndLastGlyph) { return {notEnoughRoom: true}; } - const firstPoint = project(firstAndLastGlyph.first.point.x, firstAndLastGlyph.first.point.y, glCoordMatrix, projectionContext.getElevation).point; - const lastPoint = project(firstAndLastGlyph.last.point.x, firstAndLastGlyph.last.point.y, glCoordMatrix, projectionContext.getElevation).point; + const firstPoint = projectFromLabelPlaneToClipSpace(firstAndLastGlyph.first.point.x, firstAndLastGlyph.first.point.y, projectionContext, pitchedLabelPlaneMatrixInverse); + const lastPoint = projectFromLabelPlaneToClipSpace(firstAndLastGlyph.last.point.x, firstAndLastGlyph.last.point.y, projectionContext, pitchedLabelPlaneMatrixInverse); if (keepUpright && !flip) { const orientationChange = requiresOrientationChange(symbol.writingMode, firstPoint, lastPoint, aspectRatio); @@ -386,18 +432,21 @@ function placeGlyphsAlongLine(projectionContext: SymbolProjectionContext, symbol // Only a single glyph to place // So, determine whether to flip based on projected angle of the line segment it's on if (keepUpright && !flip) { - const a = project(projectionContext.tileAnchorPoint.x, projectionContext.tileAnchorPoint.y, posMatrix, projectionContext.getElevation).point; + const a = projectTileCoordinatesToLabelPlane(projectionContext.tileAnchorPoint.x, projectionContext.tileAnchorPoint.y, projectionContext).point; const tileVertexIndex = (symbol.lineStartIndex + symbol.segment + 1); const tileSegmentEnd = new Point(projectionContext.lineVertexArray.getx(tileVertexIndex), projectionContext.lineVertexArray.gety(tileVertexIndex)); - const projectedVertex = project(tileSegmentEnd.x, tileSegmentEnd.y, posMatrix, projectionContext.getElevation); + const projectedVertex = projectTileCoordinatesToLabelPlane(tileSegmentEnd.x, tileSegmentEnd.y, projectionContext); // We know the anchor will be in the viewport, but the end of the line segment may be // behind the plane of the camera, in which case we can use a point at any arbitrary (closer) // point on the segment. const b = (projectedVertex.signedDistanceFromCamera > 0) ? projectedVertex.point : - projectTruncatedLineSegmentToLabelPlane(projectionContext.tileAnchorPoint, tileSegmentEnd, a, 1, posMatrix, projectionContext); + projectTruncatedLineSegmentToLabelPlane(projectionContext.tileAnchorPoint, tileSegmentEnd, a, 1, projectionContext); + + const clipSpaceA = projectFromLabelPlaneToClipSpace(a.x, a.y, projectionContext, pitchedLabelPlaneMatrixInverse); + const clipSpaceB = projectFromLabelPlaneToClipSpace(b.x, b.y, projectionContext, pitchedLabelPlaneMatrixInverse); - const orientationChange = requiresOrientationChange(symbol.writingMode, a, b, aspectRatio); + const orientationChange = requiresOrientationChange(symbol.writingMode, clipSpaceA, clipSpaceB, aspectRatio); if (orientationChange) { return orientationChange; } @@ -417,52 +466,22 @@ function placeGlyphsAlongLine(projectionContext: SymbolProjectionContext, symbol } /** - * Takes a line and direction from `previousTilePoint` to `currentTilePoint`, - * projects it to *label plane*, - * and returns a projected point along this projected line that is `minimumLength` distance away from `previousProjectedPoint`. - * @param previousTilePoint - Line start point, in tile coordinates. - * @param currentTilePoint - Line end point, in tile coordinates. - * @param previousProjectedPoint - Projection of `previousTilePoint` into *label plane*. - * @param minimumLength - Distance in the projected space along the line for the returned point. - * @param projectionMatrix - Matrix to use during projection. - * @param projectionContext - Projection context, used only for terrain's `getElevation`. - */ -function projectTruncatedLineSegmentToLabelPlane(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Point, minimumLength: number, projectionMatrix: mat4, projectionContext: SymbolProjectionContext) { - return _projectTruncatedLineSegment(previousTilePoint, currentTilePoint, previousProjectedPoint, minimumLength, projectionMatrix, projectionContext); -} - -/** - * Takes a line and direction from `previousTilePoint` to `currentTilePoint`, - * projects it to *viewport*, + * Takes a line and direction from `previousTilePoint` to `currentTilePoint`, projects it to the correct label plane, * and returns a projected point along this projected line that is `minimumLength` distance away from `previousProjectedPoint`. + * Projects a "virtual" vertex along a line segment. * @param previousTilePoint - Line start point, in tile coordinates. * @param currentTilePoint - Line end point, in tile coordinates. - * @param previousProjectedPoint - Projection of `previousTilePoint` into *viewport*. + * @param previousProjectedPoint - Projection of `previousTilePoint` into label plane * @param minimumLength - Distance in the projected space along the line for the returned point. - * @param projectionContext - Projection context, used for terrain's `getElevation`, and either the `labelPlaneMatrix` or the map's special projection (mostly for globe). + * @param projectionContext - Projection context, used to get terrain's `getElevation`, and to project the points to screen pixels. */ -function projectTruncatedLineSegmentToViewport(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Point, minimumLength: number, projectionContext: SymbolProjectionContext) { - return _projectTruncatedLineSegment(previousTilePoint, currentTilePoint, previousProjectedPoint, minimumLength, undefined, projectionContext); -} - -/** - * Do not use directly, use {@link projectTruncatedLineSegmentToLabelPlane} or {@link projectTruncatedLineSegmentToViewport} instead, - * depending on the target space. - * - * Projects a "virtual" vertex along a line segment. - * If `projectionMatrix` is not undefined, does a simple projection using this matrix. - * Otherwise, either projects to label plane using the `labelPlaneMatrix` - * or projects to viewport using the special map projection (mostly for globe) by calling {@link projectTileCoordinatesToViewport}. - */ -function _projectTruncatedLineSegment(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Point, minimumLength: number, projectionMatrix: mat4 | undefined, projectionContext: SymbolProjectionContext) { +function projectTruncatedLineSegmentToLabelPlane(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Point, minimumLength: number, projectionContext: SymbolProjectionContext) { // We are assuming "previousTilePoint" won't project to a point within one unit of the camera plane // If it did, that would mean our label extended all the way out from within the viewport to a (very distant) // point near the plane of the camera. We wouldn't be able to render the label anyway once it crossed the // plane of the camera. const unitVertexToBeProjected = previousTilePoint.add(previousTilePoint.sub(currentTilePoint)._unit()); - const projectedUnitVertex = projectionMatrix !== undefined ? - project(unitVertexToBeProjected.x, unitVertexToBeProjected.y, projectionMatrix, projectionContext.getElevation).point : - projectTileCoordinatesToViewport(unitVertexToBeProjected.x, unitVertexToBeProjected.y, projectionContext).point; + const projectedUnitVertex = projectTileCoordinatesToLabelPlane(unitVertexToBeProjected.x, unitVertexToBeProjected.y, projectionContext).point; const projectedUnitSegment = previousProjectedPoint.sub(projectedUnitVertex); return previousProjectedPoint.add(projectedUnitSegment._mult(minimumLength / projectedUnitSegment.mag())); } @@ -512,9 +531,9 @@ export type SymbolProjectionContext = { */ lineVertexArray: SymbolLineVertexArray; /** - * Label plane projection matrix + * Matrix for transforming from pixels (symbol shaping) to potentially rotated tile units (pitched map label plane). */ - labelPlaneMatrix: mat4; + pitchedLabelPlaneMatrix: mat4; /** * Function to get elevation at a point * @param x - the x coordinate @@ -530,7 +549,7 @@ export type SymbolProjectionContext = { * True when line glyphs are projected onto the map, instead of onto the viewport. */ pitchWithMap: boolean; - projection: Projection; + transform: IReadonlyTransform; unwrappedTileID: UnwrappedTileID; /** * Viewport width. @@ -562,7 +581,7 @@ export type ProjectionSyntheticVertexArgs = { * @param projectionContext - necessary data to project a vertex * @returns the vertex projected to the label plane */ -function projectLineVertexToViewport(index: number, projectionContext: SymbolProjectionContext, syntheticVertexArgs: ProjectionSyntheticVertexArgs): Point { +export function projectLineVertexToLabelPlane(index: number, projectionContext: SymbolProjectionContext, syntheticVertexArgs: ProjectionSyntheticVertexArgs): Point { const cache = projectionContext.projectionCache; if (cache.projections[index]) { @@ -572,7 +591,7 @@ function projectLineVertexToViewport(index: number, projectionContext: SymbolPro projectionContext.lineVertexArray.getx(index), projectionContext.lineVertexArray.gety(index)); - const projection = projectTileCoordinatesToViewport(currentVertex.x, currentVertex.y, projectionContext); + const projection = projectTileCoordinatesToLabelPlane(currentVertex.x, currentVertex.y, projectionContext); if (projection.signedDistanceFromCamera > 0) { cache.projections[index] = projection.point; @@ -589,21 +608,47 @@ function projectLineVertexToViewport(index: number, projectionContext: SymbolPro // Don't cache because the new vertex might not be far enough out for future glyphs on the same segment const minimumLength = syntheticVertexArgs.absOffsetX - syntheticVertexArgs.distanceFromAnchor + 1; - return projectTruncatedLineSegmentToViewport(previousTilePoint, currentVertex, syntheticVertexArgs.previousVertex, minimumLength, projectionContext); + return projectTruncatedLineSegmentToLabelPlane(previousTilePoint, currentVertex, syntheticVertexArgs.previousVertex, minimumLength, projectionContext); } -function projectTileCoordinatesToViewport(x: number, y: number, projectionContext: SymbolProjectionContext): PointProjection { +/** + * Projects the given point in tile coordinates to the correct label plane. + * If pitchWithMap is true, the (rotated) map plane in pixels is used, + * otherwise screen pixels are used. + */ +export function projectTileCoordinatesToLabelPlane(x: number, y: number, projectionContext: SymbolProjectionContext): PointProjection { const translatedX = x + projectionContext.translation[0]; const translatedY = y + projectionContext.translation[1]; let projection; - if (!projectionContext.pitchWithMap && projectionContext.projection.useSpecialProjectionForSymbols) { - projection = projectionContext.projection.projectTileCoordinates(translatedX, translatedY, projectionContext.unwrappedTileID, projectionContext.getElevation); + if (projectionContext.pitchWithMap) { + projection = projectWithMatrix(translatedX, translatedY, projectionContext.pitchedLabelPlaneMatrix, projectionContext.getElevation); + projection.isOccluded = false; + } else { + projection = projectionContext.transform.projectTileCoordinates(translatedX, translatedY, projectionContext.unwrappedTileID, projectionContext.getElevation); projection.point.x = (projection.point.x * 0.5 + 0.5) * projectionContext.width; projection.point.y = (-projection.point.y * 0.5 + 0.5) * projectionContext.height; + } + return projection; +} + +function projectFromLabelPlaneToClipSpace(x: number, y: number, projectionContext: SymbolProjectionContext, pitchedLabelPlaneMatrixInverse: mat4): {x: number; y: number} { + if (projectionContext.pitchWithMap) { + const pos = [x, y, 0, 1] as vec4; + vec4.transformMat4(pos, pos, pitchedLabelPlaneMatrixInverse); + return projectionContext.transform.projectTileCoordinates(pos[0] / pos[3], pos[1] / pos[3], projectionContext.unwrappedTileID, projectionContext.getElevation).point; } else { - projection = project(translatedX, translatedY, projectionContext.labelPlaneMatrix, projectionContext.getElevation); - projection.isOccluded = false; + return { + x: (x / projectionContext.width) * 2.0 - 1.0, + y: (y / projectionContext.height) * 2.0 - 1.0 + }; } +} + +/** + * Projects the given point in tile coordinates to the GL clip space (-1..1). + */ +export function projectTileCoordinatesToClipSpace(x: number, y: number, projectionContext: SymbolProjectionContext): PointProjection { + const projection = projectionContext.transform.projectTileCoordinates(x, y, projectionContext.unwrappedTileID, projectionContext.getElevation); return projection; } @@ -614,7 +659,7 @@ function projectTileCoordinatesToViewport(x: number, y: number, projectionContex * @param direction - direction of line traversal * @returns a normal vector from the segment, with magnitude equal to offset amount */ -function transformToOffsetNormal(segmentVector: Point, offset: number, direction: number): Point { +export function transformToOffsetNormal(segmentVector: Point, offset: number, direction: number): Point { return segmentVector._unit()._perp()._mult(offset * direction); } @@ -632,7 +677,7 @@ function transformToOffsetNormal(segmentVector: Point, offset: number, direction * @param projectionContext - Necessary data for tile-to-label-plane projection * @returns The point at which the current and next line segments intersect, once offset and extended/shrunk to their meeting point */ -function findOffsetIntersectionPoint( +export function findOffsetIntersectionPoint( index: number, prevToCurrentOffsetNormal: Point, currentVertex: Point, @@ -654,7 +699,7 @@ function findOffsetIntersectionPoint( return offsetCurrentVertex; } // Offset the vertices for the next segment - const nextVertex = projectLineVertexToViewport(index + syntheticVertexArgs.direction, projectionContext, syntheticVertexArgs); + const nextVertex = projectLineVertexToLabelPlane(index + syntheticVertexArgs.direction, projectionContext, syntheticVertexArgs); const currentToNextOffsetNormal = transformToOffsetNormal(nextVertex.sub(currentVertex), lineOffsetY, syntheticVertexArgs.direction); const offsetNextSegmentBegin = currentVertex.add(currentToNextOffsetNormal); const offsetNextSegmentEnd = nextVertex.add(currentToNextOffsetNormal); @@ -689,7 +734,7 @@ type PlacedGlyph = { * from the anchor point until the distance traversed in the label plane equals the glyph's * offsetX. Returns null if the glyph can't fit on the line geometry. */ -function placeGlyphAlongLine( +export function placeGlyphAlongLine( offsetX: number, lineOffsetX: number, lineOffsetY: number, @@ -720,13 +765,13 @@ function placeGlyphAlongLine( lineStartIndex + anchorSegment : lineStartIndex + anchorSegment + 1; - // Project anchor point to proper label plane and cache it + // Project anchor point to viewport and cache it let anchorPoint: Point; if (projectionContext.projectionCache.cachedAnchorPoint) { anchorPoint = projectionContext.projectionCache.cachedAnchorPoint; } else { - anchorPoint = projectTileCoordinatesToViewport(projectionContext.tileAnchorPoint.x, projectionContext.tileAnchorPoint.y, projectionContext).point; + anchorPoint = projectTileCoordinatesToLabelPlane(projectionContext.tileAnchorPoint.x, projectionContext.tileAnchorPoint.y, projectionContext).point; projectionContext.projectionCache.cachedAnchorPoint = anchorPoint; } @@ -764,7 +809,7 @@ function placeGlyphAlongLine( }; // find next vertex in viewport space - currentVertex = projectLineVertexToViewport(currentIndex, projectionContext, syntheticVertexArgs); + currentVertex = projectLineVertexToLabelPlane(currentIndex, projectionContext, syntheticVertexArgs); if (lineOffsetY === 0) { // Store vertices for collision detection and update current segment geometry pathVertices.push(previousVertex); @@ -776,7 +821,7 @@ function placeGlyphAlongLine( if (prevToCurrent.mag() === 0) { // We are starting with our anchor point directly on the vertex, so look one vertex ahead // to calculate a normal - const nextVertex = projectLineVertexToViewport(currentIndex + direction, projectionContext, syntheticVertexArgs); + const nextVertex = projectLineVertexToLabelPlane(currentIndex + direction, projectionContext, syntheticVertexArgs); prevToCurrentOffsetNormal = transformToOffsetNormal(nextVertex.sub(currentVertex), lineOffsetY, direction); } else { prevToCurrentOffsetNormal = transformToOffsetNormal(prevToCurrent, lineOffsetY, direction); @@ -812,7 +857,7 @@ const hiddenGlyphAttributes = new Float32Array([-Infinity, -Infinity, 0, -Infini // Hide them by moving them offscreen. We still need to add them to the buffer // because the dynamic buffer is paired with a static buffer that doesn't get updated. -function hideGlyphs(num: number, dynamicLayoutVertexArray: SymbolDynamicLayoutArray) { +export function hideGlyphs(num: number, dynamicLayoutVertexArray: SymbolDynamicLayoutArray) { for (let i = 0; i < num; i++) { const offset = dynamicLayoutVertexArray.length; dynamicLayoutVertexArray.resize(offset + 4); @@ -824,10 +869,58 @@ function hideGlyphs(num: number, dynamicLayoutVertexArray: SymbolDynamicLayoutAr // For line label layout, we're not using z output and our w input is always 1 // This custom matrix transformation ignores those components to make projection faster -function xyTransformMat4(out: vec4, a: vec4, m: mat4) { +export function xyTransformMat4(out: vec4, a: vec4, m: mat4) { const x = a[0], y = a[1]; out[0] = m[0] * x + m[4] * y + m[12]; out[1] = m[1] * x + m[5] * y + m[13]; out[3] = m[3] * x + m[7] * y + m[15]; return out; } + +/** + * Takes a path of points that was previously projected using the `pitchedLabelPlaneMatrix` + * and projects it using the map projection's (mercator/globe...) `projectTileCoordinates` function. + * Returns a new array of the projected points. + * Does not modify the input array. + */ +export function projectPathSpecialProjection(projectedPath: Array, projectionContext: SymbolProjectionContext): Array { + const inverseLabelPlaneMatrix = mat4.create(); + mat4.invert(inverseLabelPlaneMatrix, projectionContext.pitchedLabelPlaneMatrix); + return projectedPath.map(p => { + const backProjected = projectWithMatrix(p.x, p.y, inverseLabelPlaneMatrix, projectionContext.getElevation); + const projected = projectionContext.transform.projectTileCoordinates( + backProjected.point.x, + backProjected.point.y, + projectionContext.unwrappedTileID, + projectionContext.getElevation + ); + projected.point.x = (projected.point.x * 0.5 + 0.5) * projectionContext.width; + projected.point.y = (-projected.point.y * 0.5 + 0.5) * projectionContext.height; + return projected; + }); +} + +/** + * Takes a path of points projected to screenspace, finds the longest continuous unoccluded segment of that path + * and returns it. + * Does not modify the input array. + */ +export function pathSlicedToLongestUnoccluded(path: Array): Array { + let longestUnoccludedStart = 0; + let longestUnoccludedLength = 0; + let currentUnoccludedStart = 0; + let currentUnoccludedLength = 0; + for (let i = 0; i < path.length; i++) { + if (path[i].isOccluded) { + currentUnoccludedStart = i + 1; + currentUnoccludedLength = 0; + } else { + currentUnoccludedLength++; + if (currentUnoccludedLength > longestUnoccludedLength) { + longestUnoccludedLength = currentUnoccludedLength; + longestUnoccludedStart = currentUnoccludedStart; + } + } + } + return path.slice(longestUnoccludedStart, longestUnoccludedStart + longestUnoccludedLength); +} diff --git a/src/symbol/symbol_layout.ts b/src/symbol/symbol_layout.ts index cc1a030a7e..2261fabe65 100644 --- a/src/symbol/symbol_layout.ts +++ b/src/symbol/symbol_layout.ts @@ -32,6 +32,8 @@ import murmur3 from 'murmurhash-js'; import {getIconPadding, SymbolPadding} from '../style/style_layer/symbol_style_layer'; import {VariableAnchorOffsetCollection, classifyRings} from '@maplibre/maplibre-gl-style-spec'; import {getTextVariableAnchorOffset, evaluateVariableOffset, INVALID_TEXT_OFFSET, TextAnchor, TextAnchorEnum} from '../style/style_layer/variable_text_anchor'; +import {subdivideVertexLine} from '../render/subdivision'; +import type {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; // The symbol layout process needs `text-size` evaluated at up to five different zoom levels, and // `icon-size` at up to three: @@ -76,6 +78,7 @@ export function performSymbolLayout(args: { imagePositions: {[_: string]: ImagePosition}; showCollisionBoxes: boolean; canonical: CanonicalTileID; + subdivisionGranularity: SubdivisionGranularitySetting; }) { args.bucket.createArrays(); @@ -250,7 +253,7 @@ export function performSymbolLayout(args: { const shapedText = getDefaultHorizontalShaping(shapedTextOrientations.horizontal) || shapedTextOrientations.vertical; args.bucket.iconsInText = shapedText ? shapedText.iconsInText : false; if (shapedText || shapedIcon) { - addFeature(args.bucket, feature, shapedTextOrientations, shapedIcon, args.imageMap, sizes, layoutTextSize, layoutIconSize, textOffset, isSDFIcon, args.canonical); + addFeature(args.bucket, feature, shapedTextOrientations, shapedIcon, args.imageMap, sizes, layoutTextSize, layoutIconSize, textOffset, isSDFIcon, args.canonical, args.subdivisionGranularity); } } @@ -290,7 +293,8 @@ function addFeature(bucket: SymbolBucket, layoutIconSize: number, textOffset: [number, number], isSDFIcon: boolean, - canonical: CanonicalTileID) { + canonical: CanonicalTileID, + subdivisionGranularity: SubdivisionGranularitySetting) { // To reduce the number of labels that jump around when zooming we need // to use a text-size value that is the same for all zoom levels. // bucket calculates text-size at a high zoom level so that all tiles can @@ -330,6 +334,8 @@ function addFeature(bucket: SymbolBucket, } } + const granularity = (canonical) ? subdivisionGranularity.line.getGranularityForZoomLevel(canonical.z) : 1; + const addSymbolAtAnchor = (line, anchor) => { if (anchor.x < 0 || anchor.x >= EXTENT || anchor.y < 0 || anchor.y >= EXTENT) { // Symbol layers are drawn across tile boundaries, We filter out symbols @@ -346,8 +352,9 @@ function addFeature(bucket: SymbolBucket, if (symbolPlacement === 'line') { for (const line of clipLine(feature.geometry, 0, 0, EXTENT, EXTENT)) { + const subdividedLine = subdivideVertexLine(line, granularity); const anchors = getAnchors( - line, + subdividedLine, symbolMinDistance, textMaxAngle, shapedTextOrientations.vertical || defaultHorizontalShaping, @@ -360,7 +367,7 @@ function addFeature(bucket: SymbolBucket, for (const anchor of anchors) { const shapedText = defaultHorizontalShaping; if (!shapedText || !anchorIsTooClose(bucket, shapedText.text, textRepeatDistance, anchor)) { - addSymbolAtAnchor(line, anchor); + addSymbolAtAnchor(subdividedLine, anchor); } } } @@ -369,15 +376,16 @@ function addFeature(bucket: SymbolBucket, // "lines" with only one point are ignored as in clipLines for (const line of feature.geometry) { if (line.length > 1) { + const subdividedLine = subdivideVertexLine(line, granularity); const anchor = getCenterAnchor( - line, + subdividedLine, textMaxAngle, shapedTextOrientations.vertical || defaultHorizontalShaping, shapedIcon, glyphSize, textMaxBoxScale); if (anchor) { - addSymbolAtAnchor(line, anchor); + addSymbolAtAnchor(subdividedLine, anchor); } } } @@ -385,12 +393,14 @@ function addFeature(bucket: SymbolBucket, for (const polygon of classifyRings(feature.geometry, 0)) { // 16 here represents 2 pixels const poi = findPoleOfInaccessibility(polygon, 16); - addSymbolAtAnchor(polygon[0], new Anchor(poi.x, poi.y, 0)); + const subdividedLine = subdivideVertexLine(polygon[0], granularity, true); + addSymbolAtAnchor(subdividedLine, new Anchor(poi.x, poi.y, 0)); } } else if (feature.type === 'LineString') { // https://github.com/mapbox/mapbox-gl-js/issues/3808 for (const line of feature.geometry) { - addSymbolAtAnchor(line, new Anchor(line[0].x, line[0].y, 0)); + const subdividedLine = subdivideVertexLine(line, granularity); + addSymbolAtAnchor(subdividedLine, new Anchor(subdividedLine[0].x, subdividedLine[0].y, 0)); } } else if (feature.type === 'Point') { for (const points of feature.geometry) { diff --git a/src/ui/camera.test.ts b/src/ui/camera.test.ts index 8834b99f27..78a89479de 100644 --- a/src/ui/camera.test.ts +++ b/src/ui/camera.test.ts @@ -1,5 +1,4 @@ import {Camera, CameraOptions} from '../ui/camera'; -import {Transform} from '../geo/transform'; import {TaskQueue, TaskID} from '../util/task_queue'; import {browser} from '../util/browser'; import {fixedLngLat, fixedNum} from '../../test/unit/lib/fixed'; @@ -9,6 +8,12 @@ import {Terrain} from '../render/terrain'; import {LngLat, LngLatLike} from '../geo/lng_lat'; import {Event} from '../util/evented'; import {LngLatBounds} from '../geo/lng_lat_bounds'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; +import {GlobeTransform} from '../geo/projection/globe_transform'; +import {getZoomAdjustment} from '../geo/projection/globe_utils'; +import {GlobeCameraHelper} from '../geo/projection/globe_camera_helper'; +import {GlobeProjection} from '../geo/projection/globe'; +import {MercatorCameraHelper} from '../geo/projection/mercator_camera_helper'; beforeEach(() => { setMatchMedia(); @@ -37,11 +42,19 @@ function attachSimulateFrame(camera) { function createCamera(options?) { options = options || {}; - const transform = new Transform(0, 20, 0, 60, options.renderWorldCopies); + const transform = options.globe ? new GlobeTransform({} as any, true) : new MercatorTransform(); + transform.setMinZoom(0); + transform.setMaxZoom(20); + transform.setMinPitch(0); + transform.setMaxPitch(60); + transform.setRenderWorldCopies(options.renderWorldCopies); transform.resize(512, 512); - const camera = attachSimulateFrame(new CameraMock(transform, {} as any)) - .jumpTo(options); + const camera = attachSimulateFrame(new CameraMock(transform, new MercatorCameraHelper(), {} as any)); + if (options.globe) { + camera.cameraHelper = new GlobeCameraHelper({useGlobeRendering: true} as GlobeProjection); + } + camera.jumpTo(options); camera._update = () => {}; camera._elevateCameraIfInsideTerrain = (_tr : any) => ({}); @@ -49,6 +62,18 @@ function createCamera(options?) { return camera; } +function createCameraGlobe(options?) { + options = options || {}; + options.globe = true; + return createCamera(options); +} + +function createCameraGlobeZoomed() { + return createCameraGlobe({ + zoom: 3 + }); +} + function assertTransitionTime(done, camera, min, max) { let startTime; camera @@ -1091,9 +1116,9 @@ describe('#flyTo', () => { }); test('does not throw when cameras current zoom is above maxzoom and an offset creates infinite zoom out factor', () => { - const transform = new Transform(0, 20.9999, 0, 60, true); + const transform = new MercatorTransform(0, 20.9999, 0, 60, true); transform.resize(512, 512); - const camera = attachSimulateFrame(new CameraMock(transform, {} as any)) + const camera = attachSimulateFrame(new CameraMock(transform, new MercatorCameraHelper(), {} as any)) .jumpTo({zoom: 21, center: [0, 0]}); camera._update = () => {}; expect(() => camera.flyTo({zoom: 7.5, center: [0, 0], offset: [0, 70]})).not.toThrow(); @@ -1645,10 +1670,10 @@ describe('#flyTo', () => { })); test('respects transform\'s maxZoom', () => new Promise(done => { - const transform = new Transform(2, 10, 0, 60, false); + const transform = new MercatorTransform(2, 10, 0, 60, false); transform.resize(512, 512); - const camera = attachSimulateFrame(new CameraMock(transform, {} as any)); + const camera = attachSimulateFrame(new CameraMock(transform, new MercatorCameraHelper(), {} as any)); camera._update = () => {}; camera.on('moveend', () => { @@ -1670,10 +1695,10 @@ describe('#flyTo', () => { })); test('respects transform\'s minZoom', () => new Promise(done => { - const transform = new Transform(2, 10, 0, 60, false); + const transform = new MercatorTransform(2, 10, 0, 60, false); transform.resize(512, 512); - const camera = attachSimulateFrame(new CameraMock(transform, {} as any)); + const camera = attachSimulateFrame(new CameraMock(transform, new MercatorCameraHelper(), {} as any)); camera._update = () => {}; camera.on('moveend', () => { @@ -1773,7 +1798,9 @@ describe('#flyTo', () => { }; camera.transform = { elevation: 0, - recalculateZoom: () => true + recalculateZoom: () => true, + setMinElevationForCurrentTile: (_a) => true, + setElevation: (e) => { camera.transform.elevation = e; } }; camera._prepareElevation([10, 0]); @@ -2026,10 +2053,10 @@ describe('#cameraForBounds', () => { }); test('asymmetrical transform using LngLatBounds instance', () => { - const transform = new Transform(2, 10, 0, 60, false); + const transform = new MercatorTransform(2, 10, 0, 60, false); transform.resize(2048, 512); - const camera = attachSimulateFrame(new CameraMock(transform, {} as any)); + const camera = attachSimulateFrame(new CameraMock(transform, new MercatorCameraHelper(), {} as any)); camera._update = () => {}; const bb = new LngLatBounds(); @@ -2158,8 +2185,8 @@ describe('queryTerrainElevation', () => { test('should return the correct elevation', () => { // Set up mock transform and terrain objects - const transform = new Transform(0, 22, 0, 60, true); - transform.elevation = 50; + const transform = new MercatorTransform(0, 22, 0, 60, true); + transform.setElevation(50); const terrain = { getElevationForLngLatZoom: jest.fn().mockReturnValue(200) } as any as Terrain; @@ -2299,3 +2326,1129 @@ describe('#transformCameraUpdate', () => { expect(fixedNum(camera.getZoom())).toBe(3); }); }); + +test('createCameraGlobe returns a globe camera', () => { + const camera = createCameraGlobe(); + expect(typeof camera.cameraHelper._globe === 'undefined').toBeFalsy(); +}); + +describe('#jumpTo globe projection', () => { + describe('globe specific behavior', () => { + let camera; + + beforeEach(() => { + camera = createCameraGlobe({zoom: 1}); + }); + + test('changing center with no zoom specified should adjusts zoom', () => { + camera.jumpTo({center: [0, 40]}); + expect(camera.getCenter()).toEqual({lng: 0, lat: 40}); + expect(camera.getZoom()).toBe(0.6154999996223638); + }); + + test('changing center with zoom specified should not adjusts zoom', () => { + camera.jumpTo({center: [0, 40], zoom: 3}); + expect(camera.getCenter()).toEqual({lng: 0, lat: 40}); + expect(camera.getZoom()).toBe(3); + }); + }); + + describe('mercator test equivalents', () => { + // Modifications to this camera from one test should carry over to later tests + const camera = createCameraGlobe({zoom: 1}); + + test('sets center', () => { + camera.jumpTo({center: [1, 2]}); + expect(camera.getCenter()).toEqual({lng: 1, lat: 2}); + }); + + test('throws on invalid center argument', () => { + expect(() => { + camera.jumpTo({center: 1}); + }).toThrow(Error); + }); + + test('keeps current center if not specified', () => { + camera.jumpTo({}); + expect(camera.getCenter()).toEqual({lng: 1, lat: 2}); + }); + + test('sets zoom', () => { + camera.jumpTo({zoom: 3}); + expect(camera.getZoom()).toBe(3); + }); + + test('keeps current zoom if not specified', () => { + camera.jumpTo({}); + expect(camera.getZoom()).toBe(3); + }); + + test('sets bearing', () => { + camera.jumpTo({bearing: 4}); + expect(camera.getBearing()).toBe(4); + }); + + test('keeps current bearing if not specified', () => { + camera.jumpTo({}); + expect(camera.getBearing()).toBe(4); + }); + + test('sets pitch', () => { + camera.jumpTo({pitch: 45}); + expect(camera.getPitch()).toBe(45); + }); + + test('keeps current pitch if not specified', () => { + camera.jumpTo({}); + expect(camera.getPitch()).toBe(45); + }); + + test('sets multiple properties', () => { + camera.jumpTo({ + center: [10, 20], + zoom: 10, + bearing: 180, + pitch: 60 + }); + expect(camera.getCenter()).toEqual({lng: 10, lat: 20}); + expect(camera.getZoom()).toBe(10); + expect(camera.getBearing()).toBe(180); + expect(camera.getPitch()).toBe(60); + }); + + test('emits move events, preserving eventData', () => { + let started, moved, ended; + const eventData = {data: 'ok'}; + + camera + .on('movestart', (d) => { started = d.data; }) + .on('move', (d) => { moved = d.data; }) + .on('moveend', (d) => { ended = d.data; }); + + camera.jumpTo({center: [1, 2]}, eventData); + expect(started).toBe('ok'); + expect(moved).toBe('ok'); + expect(ended).toBe('ok'); + }); + + test('emits zoom events, preserving eventData', () => { + let started, zoomed, ended; + const eventData = {data: 'ok'}; + + camera + .on('zoomstart', (d) => { started = d.data; }) + .on('zoom', (d) => { zoomed = d.data; }) + .on('zoomend', (d) => { ended = d.data; }); + + camera.jumpTo({zoom: 3}, eventData); + expect(started).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(ended).toBe('ok'); + }); + + test('emits rotate events, preserving eventData', () => { + let started, rotated, ended; + const eventData = {data: 'ok'}; + + camera + .on('rotatestart', (d) => { started = d.data; }) + .on('rotate', (d) => { rotated = d.data; }) + .on('rotateend', (d) => { ended = d.data; }); + + camera.jumpTo({bearing: 90}, eventData); + expect(started).toBe('ok'); + expect(rotated).toBe('ok'); + expect(ended).toBe('ok'); + }); + + test('emits pitch events, preserving eventData', () => { + let started, pitched, ended; + const eventData = {data: 'ok'}; + + camera + .on('pitchstart', (d) => { started = d.data; }) + .on('pitch', (d) => { pitched = d.data; }) + .on('pitchend', (d) => { ended = d.data; }); + + camera.jumpTo({pitch: 10}, eventData); + expect(started).toBe('ok'); + expect(pitched).toBe('ok'); + expect(ended).toBe('ok'); + }); + + test('cancels in-progress easing', () => { + camera.panTo([3, 4]); + expect(camera.isEasing()).toBeTruthy(); + camera.jumpTo({center: [1, 2]}); + expect(!camera.isEasing()).toBeTruthy(); + }); + }); +}); + +describe('#easeTo globe projection', () => { + describe('globe specific behavior', () => { + let camera; + + beforeEach(() => { + camera = createCameraGlobe({zoom: 1}); + }); + + test('changing center with no zoom specified should adjusts zoom', () => { + camera.easeTo({center: [0, 40], duration: 0}); + expect(camera.getCenter()).toEqual({lng: 0, lat: 40}); + expect(camera.getZoom()).toBe(0.6154999996223638); + }); + + test('changing center with zoom specified should not adjusts zoom', () => { + camera.easeTo({center: [0, 40], zoom: 3, duration: 0}); + expect(camera.getCenter()).toEqual({lng: 0, lat: 40}); + expect(camera.getZoom()).toBe(3); + }); + }); + + describe('mercator test equivalents', () => { + test('pans to specified location', () => { + const camera = createCameraGlobe(); + camera.easeTo({center: [100, 0], duration: 0}); + expect(camera.getCenter()).toEqual({lng: 100, lat: 0}); + }); + + test('zooms to specified level', () => { + const camera = createCameraGlobe(); + camera.easeTo({zoom: 3.2, duration: 0}); + expect(camera.getZoom()).toBe(3.2); + }); + + test('rotates to specified bearing', () => { + const camera = createCameraGlobe(); + camera.easeTo({bearing: 90, duration: 0}); + expect(camera.getBearing()).toBe(90); + }); + + test('pitches to specified pitch', () => { + const camera = createCameraGlobe(); + camera.easeTo({pitch: 45, duration: 0}); + expect(camera.getPitch()).toBe(45); + }); + + test('pans and zooms', () => { + const camera = createCameraGlobe(); + camera.easeTo({center: [100, 0], zoom: 3.2, duration: 0}); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({lng: 100, lat: 0})); + expect(camera.getZoom()).toBe(3.2); + }); + + test('pans and rotates', () => { + const camera = createCameraGlobe(); + camera.easeTo({center: [100, 0], bearing: 90, duration: 0}); + expect(camera.getCenter()).toEqual({lng: 100, lat: 0}); + expect(camera.getBearing()).toBe(90); + }); + + test('zooms and rotates', () => { + const camera = createCameraGlobe(); + camera.easeTo({zoom: 3.2, bearing: 90, duration: 0}); + expect(camera.getZoom()).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('pans, zooms, and rotates', () => { + const camera = createCameraGlobe({bearing: -90}); + camera.easeTo({center: [100, 0], zoom: 3.2, bearing: 90, duration: 0}); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({lng: 100, lat: 0})); + expect(camera.getZoom()).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('noop', () => { + const camera = createCameraGlobe(); + camera.easeTo({duration: 0}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: 0, lat: 0}); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + // The behavior of "offset" differs from mercator because mercator doesn't follow the docs + // that offset should be relative to the *target* map state, not *starting* map state. + // Globe does follow the docs for now. + + test('noop with offset', () => { + const camera = createCameraGlobe(); + camera.easeTo({offset: [100, 0], duration: 0}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: -85.920282254, lat: 0}); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('pans with specified offset', () => { + const camera = createCameraGlobe(); + camera.easeTo({center: [100, 0], offset: [100, 0], duration: 0}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: 14.079717746, lat: 0}); + }); + + test('pans with specified offset relative to viewport on a rotated camera', () => { + const camera = createCameraGlobe({bearing: 180}); + camera.easeTo({center: [100, 0], offset: [100, 0], duration: 0}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: -174.079717746, lat: 0}); + }); + + test('zooms with specified offset', () => { + const camera = createCameraGlobe(); + camera.easeTo({zoom: 3.2, offset: [100, 0], duration: 0}); + expect(camera.getZoom()).toBe(3.2); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({lng: -7.742888378, lat: 0})); + }); + + test('zooms with specified offset relative to viewport on a rotated camera', () => { + const camera = createCameraGlobe({bearing: 180}); + camera.easeTo({zoom: 3.2, offset: [100, 0], duration: 0}); + expect(camera.getZoom()).toBe(3.2); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({lng: 7.742888378, lat: 0})); + }); + + test('rotates with specified offset', () => { + const camera = createCameraGlobe(); + camera.easeTo({bearing: 90, offset: [100, 0], duration: 0}); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({lng: 0, lat: 85.051129})); + }); + + test('rotates with specified offset relative to viewport on a rotated camera', () => { + const camera = createCameraGlobe({bearing: 180}); + camera.easeTo({bearing: 90, offset: [100, 0], duration: 0}); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({lng: 0, lat: 85.051129})); + }); + + test('emits zoom events if changing latitude but not zooming', async () => { + const camera = createCameraGlobe(); + + const zoomstart = jest.fn(); + const zoom = jest.fn(); + const zoomend = jest.fn(); + + expect.assertions(3); + + camera + .on('zoomstart', zoomstart) + .on('zoom', zoom) + .on('zoomend', zoomend) + .on('moveend', () => { + expect(zoomstart).toHaveBeenCalled(); + expect(zoom).toHaveBeenCalled(); + expect(zoomend).toHaveBeenCalled(); + }); + + camera.easeTo({center: [0, 20], duration: 0}); + }); + + test('does not emit zoom events if not changing latitude and not zooming', async () => { + const camera = createCameraGlobe(); + + expect.assertions(1); + + const spy = jest.fn(); + camera + .on('zoomstart', spy) + .on('zoom', spy) + .on('zoomend', spy) + .on('moveend', () => { + expect(spy).not.toHaveBeenCalled(); + }); + + camera.easeTo({center: [100, 0], duration: 0}); + }); + + test('pans eastward across the antimeridian', done => { + const camera = createCameraGlobe(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng > 170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.easeTo({center: [-170, 0], duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('does pan eastward across the antimeridian on a renderWorldCopies: false map if globe is enabled', done => { + const camera = createCameraGlobe({renderWorldCopies: false, zoom: 2}); + camera.setCenter([170, 0]); + camera.on('moveend', () => { + expect(camera.getCenter().lng).toBeCloseTo(-150, 0); + done(); + }); + camera.easeTo({center: [210, 0], duration: 0}); + }); + + test('pans westward across the antimeridian', done => { + const camera = createCameraGlobe(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng < -170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.easeTo({center: [170, 0], duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('does pan westward across the antimeridian on a renderWorldCopies: false map if globe is enabled', done => { + const camera = createCameraGlobe({renderWorldCopies: false, zoom: 2}); + camera.setCenter([-170, 0]); + camera.on('moveend', () => { + expect(camera.getCenter().lng).toBeCloseTo(150, 0); + done(); + }); + camera.easeTo({center: [-210, 0], duration: 0}); + }); + }); +}); + +describe('#flyTo globe projection', () => { + describe('globe specific behavior', () => { + let camera; + + beforeEach(() => { + camera = createCameraGlobe({zoom: 1}); + }); + + test('changing center with no zoom specified should adjusts zoom', () => { + camera.flyTo({center: [0, 40], animate: false}); + expect(camera.getCenter().lng).toBeCloseTo(0, 9); + expect(camera.getCenter().lat).toBeCloseTo(40, 9); + expect(camera.getZoom()).toBe(0.6154999996223638); + }); + + test('changing center with zoom specified should not adjusts zoom', () => { + camera.flyTo({center: [0, 40], zoom: 3, animate: false}); + expect(camera.getCenter().lng).toBeCloseTo(0, 9); + expect(camera.getCenter().lat).toBeCloseTo(40, 9); + expect(camera.getZoom()).toBe(3); + }); + }); + + describe('mercator test equivalents', () => { + test('pans to specified location', () => { + const camera = createCameraGlobe(); + camera.flyTo({center: [100, 0], animate: false}); + expect(camera.getCenter().lng).toBeCloseTo(100, 9); + expect(camera.getCenter().lat).toBeCloseTo(0, 9); + }); + + test('throws on invalid center argument', () => { + const camera = createCameraGlobe(); + expect(() => { + camera.flyTo({center: 1}); + }).toThrow(Error); + }); + + test('does not throw when cameras current zoom is sufficiently greater than passed zoom option', () => { + const camera = createCameraGlobe({zoom: 22, center: [0, 0]}); + expect(() => camera.flyTo({zoom: 10, center: [0, 0]})).not.toThrow(); + }); + + test('zooms to specified level', () => { + const camera = createCameraGlobe(); + camera.flyTo({zoom: 3.2, animate: false}); + expect(fixedNum(camera.getZoom())).toBe(3.2); + }); + + test('zooms to integer level without floating point errors', () => { + const camera = createCameraGlobe({zoom: 0.6}); + camera.flyTo({zoom: 2, animate: false}); + expect(camera.getZoom()).toBe(2); + }); + + test('Zoom out from the same position to the same position with animation', done => { + const pos = {lng: 0, lat: 0}; + const camera = createCameraGlobe({zoom: 20, center: pos}); + const stub = jest.spyOn(browser, 'now'); + + camera.once('zoomend', () => { + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat(pos)); + expect(camera.getZoom()).toBe(19); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({zoom: 19, center: pos, duration: 2}); + + stub.mockImplementation(() => 3); + camera.simulateFrame(); + }); + + test('rotates to specified bearing', () => { + const camera = createCameraGlobe(); + camera.flyTo({bearing: 90, animate: false}); + expect(camera.getBearing()).toBe(90); + }); + + test('tilts to specified pitch', () => { + const camera = createCameraGlobe(); + camera.flyTo({pitch: 45, animate: false}); + expect(camera.getPitch()).toBe(45); + }); + + test('pans and zooms', () => { + const camera = createCameraGlobe(); + camera.flyTo({center: [100, 0], zoom: 3.2, animate: false}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: 100, lat: 0}); + expect(fixedNum(camera.getZoom())).toBe(3.2); + }); + + test('pans and rotates', () => { + const camera = createCameraGlobe(); + camera.flyTo({center: [100, 0], bearing: 90, animate: false}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: 100, lat: 0}); + expect(camera.getBearing()).toBe(90); + }); + + test('zooms and rotates', () => { + const camera = createCameraGlobe(); + camera.flyTo({zoom: 3.2, bearing: 90, animate: false}); + expect(fixedNum(camera.getZoom())).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('pans, zooms, and rotates', () => { + const camera = createCameraGlobe(); + camera.flyTo({center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, animate: false}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: 100, lat: 0}); + expect(fixedNum(camera.getZoom())).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('noop', () => { + const camera = createCameraGlobe(); + camera.flyTo({animate: false}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: 0, lat: 0}); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + // Globe animations with offset are different from mercator because + // globe animations follow docs, see comment in easeTo globe tests. + + test('noop with offset', () => { + const camera = createCameraGlobe(); + camera.flyTo({offset: [100, 0], animate: false}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: -85.920282254, lat: 0}); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('pans with specified offset', () => { + const camera = createCameraGlobe(); + camera.flyTo({center: [100, 0], offset: [100, 0], animate: false}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: 14.079717746, lat: 0}); + }); + + test('pans with specified offset relative to viewport on a rotated camera', () => { + const camera = createCameraGlobe({bearing: 180}); + camera.easeTo({center: [100, 0], offset: [100, 0], animate: false}); + expect(fixedLngLat(camera.getCenter())).toEqual({lng: -174.079717746, lat: 0}); + }); + + test('emits move, zoom, rotate, and pitch events, preserving eventData', () => { + expect.assertions(18); + + const camera = createCameraGlobe(); + let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched; + const eventData = {data: 'ok'}; + + camera + .on('movestart', (d) => { movestarted = d.data; }) + .on('move', (d) => { moved = d.data; }) + .on('rotate', (d) => { rotated = d.data; }) + .on('pitch', (d) => { pitched = d.data; }) + .on('moveend', (d) => { + expect(camera._zooming).toBeFalsy(); + expect(camera._panning).toBeFalsy(); + expect(camera._rotating).toBeFalsy(); + + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(rotated).toBe('ok'); + expect(pitched).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('zoomstart', (d) => { zoomstarted = d.data; }) + .on('zoom', (d) => { zoomed = d.data; }) + .on('zoomend', (d) => { + expect(zoomstarted).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('rotatestart', (d) => { rotatestarted = d.data; }) + .on('rotate', (d) => { rotated = d.data; }) + .on('rotateend', (d) => { + expect(rotatestarted).toBe('ok'); + expect(rotated).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('pitchstart', (d) => { pitchstarted = d.data; }) + .on('pitch', (d) => { pitched = d.data; }) + .on('pitchend', (d) => { + expect(pitchstarted).toBe('ok'); + expect(pitched).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera.flyTo( + {center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45, animate: false}, + eventData); + }); + + test('for short flights, emits (solely) move events, preserving eventData', done => { + //As I type this, the code path for guiding super-short flights is (and will probably remain) different. + //As such; it deserves a separate test case. This test case flies the map from A to A. + const camera = createCameraGlobe({center: [100, 0]}); + let movestarted, moved, + zoomstarted, zoomed, zoomended, + rotatestarted, rotated, rotateended, + pitchstarted, pitched, pitchended; + const eventData = {data: 'ok'}; + + camera + .on('movestart', (d) => { movestarted = d.data; }) + .on('move', (d) => { moved = d.data; }) + .on('zoomstart', (d) => { zoomstarted = d.data; }) + .on('zoom', (d) => { zoomed = d.data; }) + .on('zoomend', (d) => { zoomended = d.data; }) + .on('rotatestart', (d) => { rotatestarted = d.data; }) + .on('rotate', (d) => { rotated = d.data; }) + .on('rotateend', (d) => { rotateended = d.data; }) + .on('pitchstart', (d) => { pitchstarted = d.data; }) + .on('pitch', (d) => { pitched = d.data; }) + .on('pitchend', (d) => { pitchended = d.data; }) + .on('moveend', (d) => { + expect(camera._zooming).toBeFalsy(); + expect(camera._panning).toBeFalsy(); + expect(camera._rotating).toBeFalsy(); + + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(zoomstarted).toBeUndefined(); + expect(zoomed).toBeUndefined(); + expect(zoomended).toBeUndefined(); + expect(rotatestarted).toBeUndefined(); + expect(rotated).toBeUndefined(); + expect(rotateended).toBeUndefined(); + expect(pitched).toBeUndefined(); + expect(pitchstarted).toBeUndefined(); + expect(pitchended).toBeUndefined(); + expect(d.data).toBe('ok'); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + + camera.flyTo({center: [100, 0], duration: 10}, eventData); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('ascends', done => { + const camera = createCameraGlobe(); + camera.setZoom(18); + let ascended; + const normalizedStartZoom = camera.getZoom() + getZoomAdjustment(camera.getCenter().lat, 0); + camera.on('zoom', () => { + const normalizedZoom = camera.getZoom() + getZoomAdjustment(camera.getCenter().lat, 0); + if (normalizedZoom < normalizedStartZoom) { + ascended = true; + } + }); + + camera.on('moveend', () => { + expect(ascended).toBeTruthy(); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + + camera.flyTo({center: [100, 0], zoom: 18, duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans eastward across the prime meridian', done => { + const camera = createCameraGlobe(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-10, 0]); + let crossedPrimeMeridian; + + camera.on('move', () => { + if (Math.abs(camera.getCenter().lng) < 10) { + crossedPrimeMeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedPrimeMeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [10, 0], duration: 20}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans westward across the prime meridian', done => { + const camera = createCameraGlobe(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([10, 0]); + let crossedPrimeMeridian; + + camera.on('move', () => { + if (Math.abs(camera.getCenter().lng) < 10) { + crossedPrimeMeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedPrimeMeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [-10, 0], duration: 20}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans eastward across the antimeridian', done => { + const camera = createCameraGlobe(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng > 170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [-170, 0], duration: 20}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans westward across the antimeridian', done => { + const camera = createCameraGlobe(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng < -170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [170, 0], duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans eastward across the antimeridian even if renderWorldCopies: false', done => { + const camera = createCameraGlobe({renderWorldCopies: false}); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng > 170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [-170, 0], duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans westward across the antimeridian even if renderWorldCopies: false', done => { + const camera = createCameraGlobe({renderWorldCopies: false}); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (fixedLngLat(camera.getCenter(), 10).lng < -170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [170, 0], duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('jumps back to world 0 when crossing the antimeridian', done => { + const camera = createCameraGlobe(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + + let leftWorld0 = false; + + camera.on('move', () => { + leftWorld0 = leftWorld0 || (camera.getCenter().lng < -180); + }); + + camera.on('moveend', () => { + expect(leftWorld0).toBeFalsy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [170, 0], duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('peaks at the specified zoom level', done => { + const camera = createCameraGlobe({zoom: 20}); + const stub = jest.spyOn(browser, 'now'); + + const minZoom = 1; + let zoomed = false; + + let leastZoom = 200; + camera.on('zoom', () => { + const zoom = camera.getZoom(); + if (zoom < 1) { + fail(`${zoom} should be >= ${minZoom} during flyTo`); + } + + leastZoom = Math.min(leastZoom, zoom); + if (zoom < (minZoom + 1)) { + zoomed = true; + } + }); + + camera.on('moveend', () => { + console.log(leastZoom); + expect(zoomed).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({center: [1, 0], zoom: 20, minZoom, duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 3); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('respects transform\'s maxZoom', done => { + const camera = createCameraGlobe(); + camera.transform.setMinZoom(2); + camera.transform.setMaxZoom(10); + + camera.on('moveend', () => { + expect(camera.getZoom()).toBeCloseTo(10); + const {lng, lat} = camera.getCenter(); + expect(lng).toBeCloseTo(12); + expect(lat).toBeCloseTo(34); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.flyTo({center: [12, 34], zoom: 30, duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }); + + test('respects transform\'s minZoom', done => { + const transform = createCameraGlobe().transform; + transform.setMinZoom(2); + transform.setMaxZoom(10); + + const camera = attachSimulateFrame(new CameraMock(transform, new MercatorCameraHelper(), {} as any)); + camera._update = () => {}; + + const start = camera.getCenter(); + const target = new LngLat(12, 34); + + camera.on('moveend', () => { + expect(camera.getZoom()).toBeCloseTo(2 + getZoomAdjustment(start.lat, target.lat)); + const {lng, lat} = camera.getCenter(); + expect(lng).toBeCloseTo(12); + expect(lat).toBeCloseTo(34); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.flyTo({center: target, zoom: 1, duration: 10}); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }); + + test('resets duration to 0 if it exceeds maxDuration', done => { + let startTime, endTime, timeDiff; + const camera = createCameraGlobe({center: [37.63454, 55.75868], zoom: 18}); + + camera + .on('movestart', () => { startTime = new Date(); }) + .on('moveend', () => { + endTime = new Date(); + timeDiff = endTime - startTime; + expect(timeDiff).toBeLessThan(30); + done(); + }); + + camera.flyTo({center: [-122.3998631, 37.7884307], maxDuration: 100}); + }); + + // No terrain/elevation tests for globe, as terrain isn't supported (yet?) + }); +}); + +describe('#fitBounds globe projection', () => { + test('no padding passed', () => { + const camera = createCameraGlobe(); + const bb = [[-133, 16], [-68, 50]]; + camera.fitBounds(bb, {duration: 0}); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -100.5, lat: 34.7171}); + expect(fixedNum(camera.getZoom(), 3)).toBe(2.496); + }); + + test('padding number', () => { + const camera = createCameraGlobe(); + const bb = [[-133, 16], [-68, 50]]; + camera.fitBounds(bb, {padding: 15, duration: 0}); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -100.5, lat: 34.7171}); + expect(fixedNum(camera.getZoom(), 3)).toBe(2.399); + }); + + test('padding object', () => { + const camera = createCameraGlobe(); + const bb = [[-133, 16], [-68, 50]]; + camera.fitBounds(bb, {padding: {top: 10, right: 75, bottom: 50, left: 25}, duration: 0}); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -96.5558, lat: 32.0833}); + }); + + test('padding does not get propagated to transform.padding', () => { + const camera = createCamera(); + const bb = [[-133, 16], [-68, 50]]; + camera.fitBounds(bb, {padding: {top: 10, right: 75, bottom: 50, left: 25}, duration: 0}); + const padding = camera.transform.padding; + + expect(padding).toEqual({ + left: 0, + right: 0, + top: 0, + bottom: 0 + }); + }); +}); + +describe('#fitScreenCoordinates globe projection', () => { + test('bearing 225', () => { + const camera = createCameraGlobeZoomed(); + const p0 = [128, 128]; + const p1 = [256, 256]; + const bearing = 225; + camera.fitScreenCoordinates(p0, p1, bearing, {duration: 0}); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -5.9948, lat: 5.8987}); + expect(fixedNum(camera.getZoom(), 3)).toBe(4.454); + expect(camera.getBearing()).toBe(-135); + }); + + test('bearing 0', () => { + const camera = createCameraGlobeZoomed(); + const p0 = [128, 128]; + const p1 = [256, 256]; + const bearing = 0; + camera.fitScreenCoordinates(p0, p1, bearing, {duration: 0}); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -5.9948, lat: 5.8987}); + expect(fixedNum(camera.getZoom(), 3)).toBe(4.936); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('inverted points', () => { + const camera = createCameraGlobeZoomed(); + const p1 = [128, 128]; + const p0 = [256, 256]; + const bearing = 0; + camera.fitScreenCoordinates(p0, p1, bearing, {duration: 0}); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -5.9948, lat: 5.8987}); + expect(fixedNum(camera.getZoom(), 3)).toBe(4.936); + expect(camera.getBearing()).toBeCloseTo(0); + }); +}); diff --git a/src/ui/camera.ts b/src/ui/camera.ts index 60b757f218..0a202bdffe 100644 --- a/src/ui/camera.ts +++ b/src/ui/camera.ts @@ -1,4 +1,4 @@ -import {extend, warnOnce, clamp, wrap, defaultEasing, pick, degreesToRadians} from '../util/util'; +import {extend, wrap, defaultEasing, pick} from '../util/util'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; import {browser} from '../util/browser'; import {LngLat} from '../geo/lng_lat'; @@ -8,12 +8,15 @@ import {Event, Evented} from '../util/evented'; import {Terrain} from '../render/terrain'; import {MercatorCoordinate} from '../geo/mercator_coordinate'; -import type {Transform} from '../geo/transform'; +import type {ITransform} from '../geo/transform_interface'; import type {LngLatLike} from '../geo/lng_lat'; import type {LngLatBoundsLike} from '../geo/lng_lat_bounds'; import type {TaskID} from '../util/task_queue'; import type {PaddingOptions} from '../geo/edge_insets'; import type {HandlerManager} from './handler_manager'; +import {scaleZoom} from '../geo/transform_helper'; +import {ICameraHelper} from '../geo/projection/camera_helper'; + /** * A [Point](https://github.com/mapbox/point-geometry) or an array of two numbers representing `x` and `y` screen coordinates in pixels. * @@ -62,10 +65,6 @@ export type CameraOptions = CenterZoomBearing & { * Increasing the pitch value is often used to display 3D objects. */ pitch?: number; - /** - * If `zoom` is specified, `around` determines the point around which the zoom is centered. - */ - around?: LngLatLike; }; /** @@ -77,7 +76,7 @@ export type CenterZoomBearing = { */ center?: LngLatLike; /** - * The desired zoom level. + * The desired mercator zoom level. */ zoom?: number; /** @@ -163,6 +162,10 @@ export type FlyToOptions = AnimationOptions & CameraOptions & { export type EaseToOptions = AnimationOptions & CameraOptions & { delayEndEvents?: number; padding?: number | RequireAtLeastOne; + /** + * If `zoom` is specified, `around` determines the point around which the zoom is centered. + */ + around?: LngLatLike; } /** @@ -241,7 +244,8 @@ export type CameraUpdateTransformFunction = (next: { }; export abstract class Camera extends Evented { - transform: Transform; + transform: ITransform; + cameraHelper: ICameraHelper; terrain: Terrain; handlers: HandlerManager; @@ -290,7 +294,7 @@ export abstract class Camera extends Evented { * @internal * Used to track accumulated changes during continuous interaction */ - _requestedCameraState?: Transform; + _requestedCameraState?: ITransform; /** * A callback used to defer camera updates or apply arbitrary constraints. * If specified, this Camera instance can be used as a stateless component in React etc. @@ -300,7 +304,7 @@ export abstract class Camera extends Evented { abstract _requestRenderFrame(a: () => void): TaskID; abstract _cancelRenderFrame(_: TaskID): void; - constructor(transform: Transform, options: { + constructor(transform: ITransform, cameraHelper: ICameraHelper, options: { bearingSnap: number; }) { super(); @@ -308,12 +312,25 @@ export abstract class Camera extends Evented { this._zooming = false; this.transform = transform; this._bearingSnap = options.bearingSnap; + this.cameraHelper = cameraHelper; this.on('moveend', () => { delete this._requestedCameraState; }); } + /** + * @internal + * Creates a new specialized transform instance from a projection instance and migrates + * to this new transform, carrying over all the properties of the old transform (center, pitch, etc.). + * When the style's projection is changed (or first set), this function should be called. + */ + migrateProjection(newTransform: ITransform, newCameraHelper: ICameraHelper) { + newTransform.apply(this.transform); + this.transform = newTransform; + this.cameraHelper = newCameraHelper; + } + /** * Returns the map's geographical centerpoint. * @@ -637,7 +654,7 @@ export abstract class Camera extends Evented { * @internal * Calculate the center of these two points in the viewport and use * the highest zoom level up to and including `Map#getMaxZoom()` that fits - * the points in the viewport at the specified bearing. + * the AABB defined by these points in the viewport at the specified bearing. * @param p0 - First point * @param p1 - Second point * @param bearing - Desired map bearing at end of animation, in degrees @@ -677,68 +694,12 @@ export abstract class Camera extends Evented { }; } - options.padding = extend(defaultPadding, options.padding) as PaddingOptions; + const padding = extend(defaultPadding, options.padding) as PaddingOptions; + options.padding = padding; const tr = this.transform; - const edgePadding = tr.padding; - - // Consider all corners of the rotated bounding box derived from the given points - // when find the camera position that fits the given points. const bounds = new LngLatBounds(p0, p1); - const nwWorld = tr.project(bounds.getNorthWest()); - const neWorld = tr.project(bounds.getNorthEast()); - const seWorld = tr.project(bounds.getSouthEast()); - const swWorld = tr.project(bounds.getSouthWest()); - - const bearingRadians = degreesToRadians(-bearing); - - const nwRotatedWorld = nwWorld.rotate(bearingRadians); - const neRotatedWorld = neWorld.rotate(bearingRadians); - const seRotatedWorld = seWorld.rotate(bearingRadians); - const swRotatedWorld = swWorld.rotate(bearingRadians); - - const upperRight = new Point( - Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), - Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) - ); - - const lowerLeft = new Point( - Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), - Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y) - ); - - // Calculate zoom: consider the original bbox and padding. - const size = upperRight.sub(lowerLeft); - const scaleX = (tr.width - (edgePadding.left + edgePadding.right + options.padding.left + options.padding.right)) / size.x; - const scaleY = (tr.height - (edgePadding.top + edgePadding.bottom + options.padding.top + options.padding.bottom)) / size.y; - - if (scaleY < 0 || scaleX < 0) { - warnOnce( - 'Map cannot fit within canvas with the given bounds, padding, and/or offset.' - ); - return undefined; - } - - const zoom = Math.min(tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom); - - // Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding. - const offset = Point.convert(options.offset); - const paddingOffsetX = (options.padding.left - options.padding.right) / 2; - const paddingOffsetY = (options.padding.top - options.padding.bottom) / 2; - const paddingOffset = new Point(paddingOffsetX, paddingOffsetY); - const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing)); - const offsetAtInitialZoom = offset.add(rotatedPaddingOffset); - const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / tr.zoomScale(zoom)); - const center = tr.unproject( - // either world diagonal can be used (NW-SE or NE-SW) - nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom) - ); - - return { - center, - zoom, - bearing - }; + return this.cameraHelper.cameraForBoxAndBearing(options, padding, bounds, bearing, tr); } /** @@ -793,8 +754,8 @@ export abstract class Camera extends Evented { fitScreenCoordinates(p0: PointLike, p1: PointLike, bearing: number, options?: FitBoundsOptions, eventData?: any): this { return this._fitInternal( this._cameraForBoxAndBearing( - this.transform.pointLocation(Point.convert(p0)), - this.transform.pointLocation(Point.convert(p1)), + this.transform.screenPointToLocation(Point.convert(p0)), + this.transform.screenPointToLocation(Point.convert(p1)), bearing, options), options, @@ -843,31 +804,27 @@ export abstract class Camera extends Evented { this.stop(); const tr = this._getTransformForUpdate(); - let zoomChanged = false, - bearingChanged = false, + let bearingChanged = false, pitchChanged = false; - if ('zoom' in options && tr.zoom !== +options.zoom) { - zoomChanged = true; - tr.zoom = +options.zoom; - } + const oldZoom = tr.zoom; - if (options.center !== undefined) { - tr.center = LngLat.convert(options.center); - } + this.cameraHelper.handleJumpToCenterZoom(tr, options); + + const zoomChanged = tr.zoom !== oldZoom; if ('bearing' in options && tr.bearing !== +options.bearing) { bearingChanged = true; - tr.bearing = +options.bearing; + tr.setBearing(+options.bearing); } if ('pitch' in options && tr.pitch !== +options.pitch) { pitchChanged = true; - tr.pitch = +options.pitch; + tr.setPitch(+options.pitch); } if (options.padding != null && !tr.isPaddingEqual(options.padding)) { - tr.padding = options.padding; + tr.setPadding(options.padding); } this._applyUpdatedTransform(tr); @@ -916,7 +873,7 @@ export abstract class Camera extends Evented { const groundDistance = Math.hypot(dx, dy); - const zoom = this.transform.scaleZoom(this.transform.cameraToCenterDistance / distance3D / this.transform.tileSize); + const zoom = scaleZoom(this.transform.cameraToCenterDistance / distance3D / this.transform.tileSize); const bearing = (Math.atan2(dx, -dy) * 180) / Math.PI; let pitch = (Math.acos(groundDistance / distance3D) * 180) / Math.PI; pitch = dz < 0 ? 90 - pitch : 90 + pitch; @@ -958,37 +915,23 @@ export abstract class Camera extends Evented { easing: defaultEasing }, options); - if (options.animate === false || (!options.essential && browser.prefersReducedMotion)) options.duration = 0; + if (options.animate === false || (!options.essential && browser.prefersReducedMotion)) { + options.duration = 0; + } - const tr = this._getTransformForUpdate(), - startZoom = tr.zoom, - startBearing = tr.bearing, + const tr = this._getTransformForUpdate(); + const startBearing = this.getBearing(), startPitch = tr.pitch, - startPadding = tr.padding, - bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing, pitch = 'pitch' in options ? +options.pitch : startPitch, - padding = 'padding' in options ? options.padding : tr.padding; - + padding = ('padding' in options ? options.padding : tr.padding) as PaddingOptions; const offsetAsPoint = Point.convert(options.offset); - let pointAtOffset = tr.centerPoint.add(offsetAsPoint); - const locationAtOffset = tr.pointLocation(pointAtOffset); - - const {center, zoom} = tr.getConstrained( - LngLat.convert(options.center || locationAtOffset), - options.zoom ?? startZoom - ); - this._normalizeCenter(center, tr); - - const from = tr.project(locationAtOffset); - const delta = tr.project(center).sub(from); - const finalScale = tr.zoomScale(zoom - startZoom); let around, aroundPoint; if (options.around) { around = LngLat.convert(options.around); - aroundPoint = tr.locationPoint(around); + aroundPoint = tr.locationToScreenPoint(around); } const currently = { @@ -998,48 +941,34 @@ export abstract class Camera extends Evented { pitching: this._pitching }; - this._zooming = this._zooming || (zoom !== startZoom); + const easeHandler = this.cameraHelper.handleEaseTo(tr, { + bearing, + pitch, + padding, + around, + aroundPoint, + offsetAsPoint, + offset: options.offset, + zoom: options.zoom, + center: options.center, + }); + this._rotating = this._rotating || (startBearing !== bearing); this._pitching = this._pitching || (pitch !== startPitch); this._padding = !tr.isPaddingEqual(padding as PaddingOptions); - + this._zooming = this._zooming || easeHandler.isZooming; this._easeId = options.easeId; this._prepareEase(eventData, options.noMoveStart, currently); - if (this.terrain) this._prepareElevation(center); + + if (this.terrain) { + this._prepareElevation(easeHandler.elevationCenter); + } this._ease((k) => { - if (this._zooming) { - tr.zoom = interpolates.number(startZoom, zoom, k); - } - if (this._rotating) { - tr.bearing = interpolates.number(startBearing, bearing, k); - } - if (this._pitching) { - tr.pitch = interpolates.number(startPitch, pitch, k); - } - if (this._padding) { - tr.interpolatePadding(startPadding, padding as PaddingOptions, k); - // When padding is being applied, Transform#centerPoint is changing continuously, - // thus we need to recalculate offsetPoint every frame - pointAtOffset = tr.centerPoint.add(offsetAsPoint); - } + easeHandler.easeFunc(k); if (this.terrain && !options.freezeElevation) this._updateElevation(k); - - if (around) { - tr.setLocationAtPoint(around, aroundPoint); - } else { - const scale = tr.zoomScale(tr.zoom - startZoom); - const base = zoom > startZoom ? - Math.min(2, finalScale) : - Math.max(0.5, finalScale); - const speedup = Math.pow(base, 1 - k); - const newCenter = tr.unproject(from.add(delta.mult(k * speedup)).mult(scale)); - tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); - } - this._applyUpdatedTransform(tr); - this._fireMoveEvents(eventData); }, (interruptingEaseId?: string) => { @@ -1074,7 +1003,7 @@ export abstract class Camera extends Evented { } _updateElevation(k: number) { - this.transform.minElevationForCurrentTile = this.terrain.getMinTileElevationForLngLatZoom(this._elevationCenter, this.transform.tileZoom); + this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this._elevationCenter, this.transform.tileZoom)); const elevation = this.terrain.getElevationForLngLatZoom(this._elevationCenter, this.transform.tileZoom); // target terrain updated during flight, slowly move camera to new height if (k < 1 && elevation !== this._elevationTarget) { @@ -1083,7 +1012,7 @@ export abstract class Camera extends Evented { this._elevationStart += k * (pitch1 - pitch2); this._elevationTarget = elevation; } - this.transform.elevation = interpolates.number(this._elevationStart, this._elevationTarget, k); + this.transform.setElevation(interpolates.number(this._elevationStart, this._elevationTarget, k)); } _finalizeElevation() { @@ -1100,7 +1029,7 @@ export abstract class Camera extends Evented { * It may differ from the state used for rendering (`this.transform`). * @returns Transform to apply changes to */ - _getTransformForUpdate(): Transform { + _getTransformForUpdate(): ITransform { if (!this.transformCameraUpdate && !this.terrain) return this.transform; if (!this._requestedCameraState) { @@ -1120,12 +1049,13 @@ export abstract class Camera extends Evented { * * @param tr - The transform to check. */ - _elevateCameraIfInsideTerrain(tr: Transform) : { pitch?: number; zoom?: number } { - const camera = tr.getCameraPosition(); - const minAltitude = this.terrain.getElevationForLngLatZoom(camera.lngLat, tr.zoom); - if (camera.altitude < minAltitude) { + _elevateCameraIfInsideTerrain(tr: ITransform) : { pitch?: number; zoom?: number } { + const cameraLngLat = tr.screenPointToLocation(tr.getCameraPoint()); + const cameraAltitude = tr.getCameraAltitude(); + const minAltitude = this.terrain.getElevationForLngLatZoom(cameraLngLat, tr.zoom); + if (cameraAltitude < minAltitude) { const newCamera = this.calculateCameraOptionsFromTo( - camera.lngLat, minAltitude, tr.center, tr.elevation); + cameraLngLat, minAltitude, tr.center, tr.elevation); return { pitch: newCamera.pitch, zoom: newCamera.zoom, @@ -1141,8 +1071,8 @@ export abstract class Camera extends Evented { * If the camera is inside terrain, it gets elevated. * Call `transformCameraUpdate` if present, and then apply the "approved" changes. */ - _applyUpdatedTransform(tr: Transform) { - const modifiers : ((tr: Transform) => ReturnType)[] = []; + _applyUpdatedTransform(tr: ITransform) { + const modifiers : ((tr: ITransform) => ReturnType)[] = []; if (this.terrain) { modifiers.push(tr => this._elevateCameraIfInsideTerrain(tr)); } @@ -1162,11 +1092,11 @@ export abstract class Camera extends Evented { bearing, elevation } = modifier(nextTransform); - if (center) nextTransform.center = center; - if (zoom !== undefined) nextTransform.zoom = zoom; - if (pitch !== undefined) nextTransform.pitch = pitch; - if (bearing !== undefined) nextTransform.bearing = bearing; - if (elevation !== undefined) nextTransform.elevation = elevation; + if (center) nextTransform.setCenter(center); + if (zoom !== undefined) nextTransform.setZoom(zoom); + if (pitch !== undefined) nextTransform.setPitch(pitch); + if (bearing !== undefined) nextTransform.setBearing(bearing); + if (elevation !== undefined) nextTransform.setElevation(elevation); finalTransform.apply(nextTransform); } this.transform.apply(finalTransform); @@ -1252,7 +1182,7 @@ export abstract class Camera extends Evented { flyTo(options: FlyToOptions, eventData?: any): this { // Fall through to jumpTo if user has set prefers-reduced-motion if (!options.essential && browser.prefersReducedMotion) { - const coercedOptions = pick(options, ['center', 'zoom', 'bearing', 'pitch', 'around']) as CameraOptions; + const coercedOptions = pick(options, ['center', 'zoom', 'bearing', 'pitch']) as CameraOptions; return this.jumpTo(coercedOptions, eventData); } @@ -1274,44 +1204,43 @@ export abstract class Camera extends Evented { }, options); const tr = this._getTransformForUpdate(), - startZoom = tr.zoom, startBearing = tr.bearing, startPitch = tr.pitch, startPadding = tr.padding; const bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing; const pitch = 'pitch' in options ? +options.pitch : startPitch; - const padding = 'padding' in options ? options.padding : tr.padding; + const padding = ('padding' in options ? options.padding : tr.padding) as PaddingOptions; const offsetAsPoint = Point.convert(options.offset); let pointAtOffset = tr.centerPoint.add(offsetAsPoint); - const locationAtOffset = tr.pointLocation(pointAtOffset); + const locationAtOffset = tr.screenPointToLocation(pointAtOffset); - const {center, zoom} = tr.getConstrained( - LngLat.convert(options.center || locationAtOffset), - options.zoom ?? startZoom - ); - this._normalizeCenter(center, tr); - const scale = tr.zoomScale(zoom - startZoom); - - const from = tr.project(locationAtOffset); - const delta = tr.project(center).sub(from); + const flyToHandler = this.cameraHelper.handleFlyTo(tr, { + bearing, + pitch, + padding, + locationAtOffset, + offsetAsPoint, + center: options.center, + minZoom: options.minZoom, + zoom: options.zoom, + }); let rho = options.curve; // w₀: Initial visible span, measured in pixels at the initial scale. - const w0 = Math.max(tr.width, tr.height), - // w₁: Final visible span, measured in pixels with respect to the initial scale. - w1 = w0 / scale, - // Length of the flight path as projected onto the ground plane, measured in pixels from - // the world image origin at the initial scale. - u1 = delta.mag(); - - if ('minZoom' in options) { - const minZoom = clamp(Math.min(options.minZoom, startZoom, zoom), tr.minZoom, tr.maxZoom); + const w0 = Math.max(tr.width, tr.height); + // w₁: Final visible span, measured in pixels with respect to the initial scale. + const w1 = w0 / flyToHandler.scaleOfZoom; + // Length of the flight path as projected onto the ground plane, measured in pixels from + // the world image origin at the initial scale. + const u1 = flyToHandler.pixelPathLength; + + if (typeof flyToHandler.scaleOfMinZoom === 'number') { // wm: Maximum visible span, measured in pixels with respect to the initial // scale. - const wMax = w0 / tr.zoomScale(minZoom - startZoom); + const wMax = w0 / flyToHandler.scaleOfMinZoom; rho = Math.sqrt(wMax / u1 * 2); } @@ -1351,7 +1280,7 @@ export abstract class Camera extends Evented { let S = (zoomOutFactor(true) - r0) / rho; // When u₀ = u₁, the optimal path doesn’t require both ascent and descent. - if (Math.abs(u1) < 0.000001 || !isFinite(S)) { + if (Math.abs(u1) < 0.000002 || !isFinite(S)) { // Perform a more or less instantaneous transition if the path is too short. if (Math.abs(w0 - w1) < 0.000001) return this.easeTo(options, eventData); @@ -1379,19 +1308,18 @@ export abstract class Camera extends Evented { this._padding = !tr.isPaddingEqual(padding as PaddingOptions); this._prepareEase(eventData, false); - if (this.terrain) this._prepareElevation(center); + if (this.terrain) this._prepareElevation(flyToHandler.targetCenter); this._ease((k) => { // s: The distance traveled along the flight path, measured in ρ-screenfuls. const s = k * S; const scale = 1 / w(s); - tr.zoom = k === 1 ? zoom : startZoom + tr.scaleZoom(scale); - + const centerFactor = u(s); if (this._rotating) { - tr.bearing = interpolates.number(startBearing, bearing, k); + tr.setBearing(interpolates.number(startBearing, bearing, k)); } if (this._pitching) { - tr.pitch = interpolates.number(startPitch, pitch, k); + tr.setPitch(interpolates.number(startPitch, pitch, k)); } if (this._padding) { tr.interpolatePadding(startPadding, padding as PaddingOptions, k); @@ -1400,15 +1328,11 @@ export abstract class Camera extends Evented { pointAtOffset = tr.centerPoint.add(offsetAsPoint); } - if (this.terrain && !options.freezeElevation) this._updateElevation(k); - - const newCenter = k === 1 ? center : tr.unproject(from.add(delta.mult(u(s))).mult(scale)); - tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); + flyToHandler.easeFunc(k, scale, centerFactor, pointAtOffset); + if (this.terrain && !options.freezeElevation) this._updateElevation(k); this._applyUpdatedTransform(tr); - this._fireMoveEvents(eventData); - }, () => { if (this.terrain && options.freezeElevation) this._finalizeElevation(); this._afterEase(eventData); @@ -1490,17 +1414,6 @@ export abstract class Camera extends Evented { return bearing; } - // If a path crossing the antimeridian would be shorter, extend the final coordinate so that - // interpolating between the two endpoints will cross it. - _normalizeCenter(center: LngLat, tr: Transform) { - if (!tr.renderWorldCopies || tr.lngRange) return; - - const delta = center.lng - tr.center.lng; - center.lng += - delta > 180 ? -360 : - delta < -180 ? 360 : 0; - } - /** * Get the elevation difference between a given point * and a point that is currently in the middle of the screen. diff --git a/src/ui/events.ts b/src/ui/events.ts index d5b174a180..bc12096e00 100644 --- a/src/ui/events.ts +++ b/src/ui/events.ts @@ -7,7 +7,7 @@ import type {MapGeoJSONFeature} from '../util/vectortile_to_geojson'; import type {Map} from './map'; import type {LngLat} from '../geo/lng_lat'; -import type {SourceSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {ProjectionSpecification, SourceSpecification} from '@maplibre/maplibre-gl-style-spec'; /** * An event from the mouse relevant to a specific layer. @@ -418,6 +418,10 @@ export type MapEventType = { cooperativegestureprevented: MapLibreEvent & { gestureType: 'wheel_zoom' | 'touch_pan'; }; + /** + * Fired when map's projection is modified in other ways than by map being moved. + */ + projectiontransition: MapProjectionEvent; }; /** @@ -735,6 +739,20 @@ export type MapTerrainEvent = { type: 'terrain'; }; +/** + * The map projection event + * + * @group Event Related + */ +export type MapProjectionEvent = { + type: 'projectiontransition'; + /** + * Specifies the name of the new projection. + * Additionally includes 'globe-mercator' to describe globe that has internally switched to mercator. + */ + newProjection: ProjectionSpecification['type'] | 'globe-mercator'; +} + /** * An event related to the web gl context * diff --git a/src/ui/handler/scroll_zoom.ts b/src/ui/handler/scroll_zoom.ts index 74089c761e..31f6a5fded 100644 --- a/src/ui/handler/scroll_zoom.ts +++ b/src/ui/handler/scroll_zoom.ts @@ -10,6 +10,7 @@ import type {Map} from '../map'; import type Point from '@mapbox/point-geometry'; import type {AroundCenterOptions} from './two_fingers_touch'; import {Handler} from '../handler_manager'; +import {scaleZoom, zoomScale} from '../../geo/transform_helper'; // deltaY value for mouse scroll wheel identification const wheelZoomDelta = 4.000244140625; @@ -35,7 +36,6 @@ export class ScrollZoomHandler implements Handler { _active: boolean; _zooming: boolean; _aroundCenter: boolean; - _around: LngLat; _aroundPoint: Point; _type: 'wheel' | 'trackpad' | null; _lastValue: number; @@ -45,6 +45,7 @@ export class ScrollZoomHandler implements Handler { _lastWheelEvent: any; _lastWheelEventTime: number; + _lastExpectedZoom: number; _startZoom: number; _targetZoom: number; _delta: number; @@ -248,15 +249,13 @@ export class ScrollZoomHandler implements Handler { const pos = DOM.mousePos(this._map.getCanvas(), e); const tr = this._tr; - if (pos.y > tr.transform.height / 2 - tr.transform.getHorizon()) { - this._around = LngLat.convert(this._aroundCenter ? tr.center : tr.unproject(pos)); + // Whether aroundPoint is actually unprojectable is not a problem to be solved here, but in handler_manager.ts instead. + if (this._aroundCenter) { + this._aroundPoint = tr.transform.locationToScreenPoint(LngLat.convert(tr.center)); } else { - // Do not use current cursor position if above the horizon to avoid 'unproject' this point - // as it is not mapped into 'coords' framebuffer or inversible with 'pixelMatrixInverse'. - this._around = LngLat.convert(tr.center); + this._aroundPoint = pos; } - this._aroundPoint = tr.transform.locationPoint(this._around); if (!this._frameId) { this._frameId = true; this._triggerRenderFrame(); @@ -270,6 +269,17 @@ export class ScrollZoomHandler implements Handler { if (!this.isActive()) return; const tr = this._tr.transform; + // When globe is enabled zoom might be modified by the map center latitude being changes (either by panning or by zoom moving the map) + if (typeof this._lastExpectedZoom === 'number') { + const externalZoomChange = tr.zoom - this._lastExpectedZoom; + if (typeof this._startZoom === 'number') { + this._startZoom += externalZoomChange; + } + if (typeof this._targetZoom === 'number') { + this._targetZoom += externalZoomChange; + } + } + // if we've had scroll events since the last render frame, consume the // accumulated delta, and update the target zoom level accordingly if (this._delta !== 0) { @@ -282,8 +292,8 @@ export class ScrollZoomHandler implements Handler { scale = 1 / scale; } - const fromScale = typeof this._targetZoom === 'number' ? tr.zoomScale(this._targetZoom) : tr.scale; - this._targetZoom = Math.min(tr.maxZoom, Math.max(tr.minZoom, tr.scaleZoom(fromScale * scale))); + const fromScale = typeof this._targetZoom !== 'number' ? tr.scale : zoomScale(this._targetZoom); + this._targetZoom = Math.min(tr.maxZoom, Math.max(tr.minZoom, scaleZoom(fromScale * scale))); // if this is a mouse wheel, refresh the starting zoom and easing // function we're using to smooth out the zooming between wheel @@ -296,8 +306,7 @@ export class ScrollZoomHandler implements Handler { this._delta = 0; } - const targetZoom = typeof this._targetZoom === 'number' ? - this._targetZoom : tr.zoom; + const targetZoom = typeof this._targetZoom !== 'number' ? tr.zoom : this._targetZoom; const startZoom = this._startZoom; const easing = this._easing; @@ -330,10 +339,13 @@ export class ScrollZoomHandler implements Handler { this._zooming = false; this._triggerRenderFrame(); delete this._targetZoom; + delete this._lastExpectedZoom; delete this._finishTimeout; }, 200); } + this._lastExpectedZoom = zoom; + return { noInertia: true, needsRenderFrame: !finished, @@ -371,6 +383,7 @@ export class ScrollZoomHandler implements Handler { this._active = false; this._zooming = false; delete this._targetZoom; + delete this._lastExpectedZoom; if (this._finishTimeout) { clearTimeout(this._finishTimeout); delete this._finishTimeout; diff --git a/src/ui/handler/transform-provider.ts b/src/ui/handler/transform-provider.ts index 5b98b54af7..856da8059b 100644 --- a/src/ui/handler/transform-provider.ts +++ b/src/ui/handler/transform-provider.ts @@ -1,6 +1,6 @@ import type {Map} from '../map'; import type {PointLike} from '../camera'; -import type {Transform} from '../../geo/transform'; +import type {IReadonlyTransform} from '../../geo/transform_interface'; import Point from '@mapbox/point-geometry'; import {LngLat} from '../../geo/lng_lat'; @@ -18,7 +18,7 @@ export class TransformProvider { this._map = map; } - get transform(): Transform { + get transform(): IReadonlyTransform { return this._map._requestedCameraState || this._map.transform; } @@ -39,6 +39,6 @@ export class TransformProvider { } unproject(point: PointLike): LngLat { - return this.transform.pointLocation(Point.convert(point), this._map.terrain); + return this.transform.screenPointToLocation(Point.convert(point), this._map.terrain); } } diff --git a/src/ui/handler_inertia.ts b/src/ui/handler_inertia.ts index 5b9bb653c1..69f0b24767 100644 --- a/src/ui/handler_inertia.ts +++ b/src/ui/handler_inertia.ts @@ -3,6 +3,7 @@ import type {Map} from './map'; import {bezier, clamp, extend} from '../util/util'; import Point from '@mapbox/point-geometry'; import type {DragPanOptions} from './handler/shim/drag_pan'; +import {EaseToOptions} from './camera'; const defaultInertiaOptions = { linearity: 0.3, @@ -66,7 +67,7 @@ export class HandlerInertia { inertia.shift(); } - _onMoveEnd(panInertiaOptions?: DragPanOptions | boolean) { + _onMoveEnd(panInertiaOptions?: DragPanOptions | boolean): EaseToOptions { this._drainInertiaBuffer(); if (this._inertiaBuffer.length < 2) { return; @@ -97,8 +98,10 @@ export class HandlerInertia { if (deltas.pan.mag()) { const result = calculateEasing(deltas.pan.mag(), duration, extend({}, defaultPanInertiaOptions, panInertiaOptions || {})); - easeOptions.offset = deltas.pan.mult(result.amount / deltas.pan.mag()); - easeOptions.center = this._map.transform.center; + const finalPan = deltas.pan.mult(result.amount / deltas.pan.mag()); + const computedEaseOptions = this._map.cameraHelper.handlePanInertia(finalPan, this._map.transform); + easeOptions.center = computedEaseOptions.easingCenter; + easeOptions.offset = computedEaseOptions.easingOffset; extendDuration(easeOptions, result); } diff --git a/src/ui/handler_manager.ts b/src/ui/handler_manager.ts index 33e773f0b6..49c9a562dd 100644 --- a/src/ui/handler_manager.ts +++ b/src/ui/handler_manager.ts @@ -20,6 +20,7 @@ import {CooperativeGesturesHandler} from './handler/cooperative_gestures'; import {extend} from '../util/util'; import {browser} from '../util/browser'; import Point from '@mapbox/point-geometry'; +import {MapControlsDeltas} from '../geo/projection/camera_helper'; const isMoving = (p: EventsInProgress) => p.zoom || p.drag || p.pitch || p.rotate; @@ -490,24 +491,43 @@ export class HandlerManager { return this._fireEvents(combinedEventsInProgress, deactivatedHandlers, true); } + // stop any ongoing camera animations (easeTo, flyTo) + map._stop(true); + let {panDelta, zoomDelta, bearingDelta, pitchDelta, around, pinchAround} = combinedResult; if (pinchAround !== undefined) { around = pinchAround; } - // stop any ongoing camera animations (easeTo, flyTo) - map._stop(true); - around = around || map.transform.centerPoint; - const loc = tr.pointLocation(panDelta ? around.sub(panDelta) : around); - if (bearingDelta) tr.bearing += bearingDelta; - if (pitchDelta) tr.pitch += pitchDelta; - if (zoomDelta) tr.zoom += zoomDelta; + + if (terrain && !tr.isPointOnMapSurface(around)) { + around = tr.centerPoint; + } + + const deltasForHelper: MapControlsDeltas = { + panDelta, + zoomDelta, + pitchDelta, + bearingDelta, + around, + }; + + // Pre-zoom location under the mouse cursor is required for accurate mercator panning and zooming + if (this._map.cameraHelper.useGlobeControls && !tr.isPointOnMapSurface(around)) { + around = tr.centerPoint; + } + const preZoomAroundLoc = tr.screenPointToLocation(panDelta ? around.sub(panDelta) : around); if (!terrain) { - tr.setLocationAtPoint(loc, around); + // Apply zoom, bearing, pitch + this._map.cameraHelper.handleMapControlsPitchBearingZoom(deltasForHelper, tr); + // Apply panning + this._map.cameraHelper.handleMapControlsPan(deltasForHelper, tr, preZoomAroundLoc); } else { + // Apply zoom, bearing, pitch + this._map.cameraHelper.handleMapControlsPitchBearingZoom(deltasForHelper, tr); // when 3d-terrain is enabled act a little different: // - dragging do not drag the picked point itself, instead it drags the map by pixel-delta. // With this approach it is no longer possible to pick a point from somewhere near @@ -518,12 +538,12 @@ export class HandlerManager { // When starting to drag or move, flag it and register moveend to clear flagging this._terrainMovement = true; this._map._elevationFreeze = true; - tr.setLocationAtPoint(loc, around); + this._map.cameraHelper.handleMapControlsPan(deltasForHelper, tr, preZoomAroundLoc); } else if (combinedEventsInProgress.drag && this._terrainMovement) { // drag map - tr.center = tr.pointLocation(tr.centerPoint.sub(panDelta)); + tr.setCenter(tr.screenPointToLocation(tr.centerPoint.sub(panDelta))); } else { - tr.setLocationAtPoint(loc, around); + this._map.cameraHelper.handleMapControlsPan(deltasForHelper, tr, preZoomAroundLoc); } } diff --git a/src/ui/map.ts b/src/ui/map.ts index 0e23f82892..551fc2248d 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -8,7 +8,6 @@ import {RequestManager, ResourceType} from '../util/request_manager'; import {Style, StyleSwapOptions} from '../style/style'; import {EvaluationParameters} from '../style/evaluation_parameters'; import {Painter} from '../render/painter'; -import {Transform} from '../geo/transform'; import {Hash} from './hash'; import {HandlerManager} from './handler_manager'; import {Camera, CameraOptions, CameraUpdateTransformFunction, FitBoundsOptions} from './camera'; @@ -54,12 +53,17 @@ import type { LightSpecification, SourceSpecification, TerrainSpecification, + ProjectionSpecification, SkySpecification } from '@maplibre/maplibre-gl-style-spec'; import type {CanvasSourceSpecification} from '../source/canvas_source'; import type {MapGeoJSONFeature} from '../util/vectortile_to_geojson'; import type {ControlPosition, IControl} from './control/control'; import type {QueryRenderedFeaturesOptions, QuerySourceFeatureOptions} from '../source/query_features'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; +import {ITransform} from '../geo/transform_interface'; +import {ICameraHelper} from '../geo/projection/camera_helper'; +import {MercatorCameraHelper} from '../geo/projection/mercator_camera_helper'; const version = packageJSON.version; @@ -441,6 +445,7 @@ const defaultOptions: Readonly> = { export class Map extends Camera { style: Style; painter: Painter; + handlers: HandlerManager; _container: HTMLElement; _canvasContainer: HTMLElement; @@ -580,8 +585,28 @@ export class Map extends Camera { throw new Error(`maxPitch must be less than or equal to ${maxPitchThreshold}`); } - const transform = new Transform(resolvedOptions.minZoom, resolvedOptions.maxZoom, resolvedOptions.minPitch, resolvedOptions.maxPitch, resolvedOptions.renderWorldCopies); - super(transform, {bearingSnap: resolvedOptions.bearingSnap}); + // For now we will use a temporary MercatorTransform instance. + // Transform specialization will later be set by style when it creates its projection instance. + // When this happens, the new transform will inherit all properties of this temporary transform. + const transform = new MercatorTransform(); + const cameraHelper = new MercatorCameraHelper(); + if (resolvedOptions.minZoom !== undefined) { + transform.setMinZoom(resolvedOptions.minZoom); + } + if (resolvedOptions.maxZoom !== undefined) { + transform.setMaxZoom(resolvedOptions.maxZoom); + } + if (resolvedOptions.minPitch !== undefined) { + transform.setMinPitch(resolvedOptions.minPitch); + } + if (resolvedOptions.maxPitch !== undefined) { + transform.setMaxPitch(resolvedOptions.maxPitch); + } + if (resolvedOptions.renderWorldCopies !== undefined) { + transform.setRenderWorldCopies(resolvedOptions.renderWorldCopies); + } + + super(transform, cameraHelper, {bearingSnap: resolvedOptions.bearingSnap}); this._interactive = resolvedOptions.interactive; this._maxTileCacheSize = resolvedOptions.maxTileCacheSize; @@ -714,7 +739,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. @@ -753,7 +778,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. * @example @@ -974,7 +999,7 @@ export class Map extends Camera { minZoom = minZoom === null || minZoom === undefined ? defaultMinZoom : minZoom; if (minZoom >= defaultMinZoom && minZoom <= this.transform.maxZoom) { - this.transform.minZoom = minZoom; + this.transform.setMinZoom(minZoom); this._update(); if (this.getZoom() < minZoom) this.setZoom(minZoom); @@ -1014,7 +1039,7 @@ export class Map extends Camera { maxZoom = maxZoom === null || maxZoom === undefined ? defaultMaxZoom : maxZoom; if (maxZoom >= this.transform.minZoom) { - this.transform.maxZoom = maxZoom; + this.transform.setMaxZoom(maxZoom); this._update(); if (this.getZoom() > maxZoom) this.setZoom(maxZoom); @@ -1054,7 +1079,7 @@ export class Map extends Camera { } if (minPitch >= defaultMinPitch && minPitch <= this.transform.maxPitch) { - this.transform.minPitch = minPitch; + this.transform.setMinPitch(minPitch); this._update(); if (this.getPitch() < minPitch) this.setPitch(minPitch); @@ -1090,7 +1115,7 @@ export class Map extends Camera { } if (maxPitch >= this.transform.minPitch) { - this.transform.maxPitch = maxPitch; + this.transform.setMaxPitch(maxPitch); this._update(); if (this.getPitch() > maxPitch) this.setPitch(maxPitch); @@ -1141,7 +1166,7 @@ export class Map extends Camera { * @see [Render world copies](https://maplibre.org/maplibre-gl-js/docs/examples/render-world-copies/) */ setRenderWorldCopies(renderWorldCopies?: boolean | null): Map { - this.transform.renderWorldCopies = renderWorldCopies; + this.transform.setRenderWorldCopies(renderWorldCopies); return this._update(); } @@ -1158,7 +1183,7 @@ export class Map extends Camera { * ``` */ project(lnglat: LngLatLike): Point { - return this.transform.locationPoint(LngLat.convert(lnglat), this.style && this.terrain); + return this.transform.locationToScreenPoint(LngLat.convert(lnglat), this.style && this.terrain); } /** @@ -1176,7 +1201,7 @@ export class Map extends Camera { * ``` */ unproject(point: PointLike): LngLat { - return this.transform.pointLocation(Point.convert(point), this.terrain); + return this.transform.screenPointToLocation(Point.convert(point), this.terrain); } /** @@ -1809,6 +1834,7 @@ export class Map extends Camera { } if (!style) { + this.style?.projection?.destroy(); delete this.style; return this; } else { @@ -1983,8 +2009,8 @@ export class Map extends Camera { this.terrain = null; if (this.painter.renderToTexture) this.painter.renderToTexture.destruct(); this.painter.renderToTexture = null; - this.transform.minElevationForCurrentTile = 0; - this.transform.elevation = 0; + this.transform.setMinElevationForCurrentTile(0); + this.transform.setElevation(0); } else { // add terrain const sourceCache = this.style.sourceCaches[options.source]; @@ -2000,15 +2026,15 @@ export class Map extends Camera { } this.terrain = new Terrain(this.painter, sourceCache, options); this.painter.renderToTexture = new RenderToTexture(this.painter, this.terrain); - this.transform.minElevationForCurrentTile = this.terrain.getMinTileElevationForLngLatZoom(this.transform.center, this.transform.tileZoom); - this.transform.elevation = this.terrain.getElevationForLngLatZoom(this.transform.center, this.transform.tileZoom); + this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this.transform.center, this.transform.tileZoom)); + this.transform.setElevation(this.terrain.getElevationForLngLatZoom(this.transform.center, this.transform.tileZoom)); this._terrainDataCallback = e => { if (e.dataType === 'style') { this.terrain.sourceCache.freeRtt(); } else if (e.dataType === 'source' && e.tile) { if (e.sourceId === options.source && !this._elevationFreeze) { - this.transform.minElevationForCurrentTile = this.terrain.getMinTileElevationForLngLatZoom(this.transform.center, this.transform.tileZoom); - this.transform.elevation = this.terrain.getElevationForLngLatZoom(this.transform.center, this.transform.tileZoom); + this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this.transform.center, this.transform.tileZoom)); + this.transform.setElevation(this.terrain.getElevationForLngLatZoom(this.transform.center, this.transform.tileZoom)); } this.terrain.sourceCache.freeRtt(e.tile.tileID); } @@ -2187,7 +2213,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` @@ -2256,7 +2282,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. * @@ -2435,7 +2461,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 * @@ -2733,23 +2759,24 @@ export class Map extends Camera { } /** - * Loads sky and fog defined by {@link SkySpecification} onto the map. - * Note: The fog only shows when using the terrain 3D feature. + * Sets the value of style's sky properties. + * * @param sky - Sky properties to set. Must conform to the [MapLibre Style Specification](https://maplibre.org/maplibre-style-spec/sky/). - * @returns `this` + * @param options - Options object. + * * @example * ```ts - * map.setSky({ 'sky-color': '#00f' }); + * map.setSky({'atmosphere-blend': 1.0}); * ``` */ - setSky(sky: SkySpecification) { + setSky(sky: SkySpecification, options: StyleSetterOptions = {}) { this._lazyInitEmptyStyle(); - this.style.setSky(sky); + this.style.setSky(sky, options); return this._update(true); } /** - * Returns the value of the sky object. + * Returns the value of the style's sky. * * @returns the sky properties of the style. * @example @@ -2757,7 +2784,7 @@ export class Map extends Camera { * map.getSky(); * ``` */ - getSky() { + getSky(): SkySpecification { return this.style.getSky(); } @@ -3010,6 +3037,14 @@ export class Map extends Camera { webpSupported.testSupport(gl); } + override migrateProjection(newTransform: ITransform, newCameraHelper: ICameraHelper) { + super.migrateProjection(newTransform, newCameraHelper); + this.painter.transform = newTransform; + this.fire(new Event('projectiontransition', { + newProjection: this.style.projection.name, + })); + } + _contextLost = (event: any) => { event.preventDefault(); if (this._frameRequest) { @@ -3131,10 +3166,12 @@ export class Map extends Camera { this.style.update(parameters); } + const transformUpdateResult = this.transform.newFrameUpdate(); + // If we are in _render for any reason other than an in-progress paint // transition, update source caches to check for and load any tiles we // need for the current transform - if (this.style && this._sourcesDirty) { + if (this.style && (this._sourcesDirty || transformUpdateResult.forceSourceUpdate)) { this._sourcesDirty = false; this.style._updateSources(this.transform); } @@ -3142,16 +3179,20 @@ export class Map extends Camera { // update terrain stuff if (this.terrain) { this.terrain.sourceCache.update(this.transform, this.terrain); - this.transform.minElevationForCurrentTile = this.terrain.getMinTileElevationForLngLatZoom(this.transform.center, this.transform.tileZoom); + this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this.transform.center, this.transform.tileZoom)); if (!this._elevationFreeze) { - this.transform.elevation = this.terrain.getElevationForLngLatZoom(this.transform.center, this.transform.tileZoom); + this.transform.setElevation(this.terrain.getElevationForLngLatZoom(this.transform.center, this.transform.tileZoom)); } } else { - this.transform.minElevationForCurrentTile = 0; - this.transform.elevation = 0; + this.transform.setMinElevationForCurrentTile(0); + this.transform.setElevation(0); } - this._placementDirty = this.style && this.style._updatePlacement(this.painter.transform, this.showCollisionBoxes, fadeDuration, this._crossSourceCollisions); + this._placementDirty = this.style && this.style._updatePlacement(this.transform, this.showCollisionBoxes, fadeDuration, this._crossSourceCollisions, transformUpdateResult.forcePlacementUpdate); + + if (transformUpdateResult.fireProjectionEvent) { + this.fire(new Event('projectiontransition', transformUpdateResult.fireProjectionEvent)); + } // Actually draw this.painter.render(this.style, { @@ -3188,7 +3229,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.style.projection.isRenderingDirty() || this.transform.isRenderingDirty(); if (somethingDirty || this._repaint) { this.triggerRepaint(); } else if (!this.isMoving() && this.loaded()) { @@ -3390,4 +3431,25 @@ export class Map extends Camera { getCameraTargetElevation(): number { return this.transform.elevation; } + + /** + * Gets the {@link ProjectionSpecification}. + * @returns the projection specification. + * @example + * ```ts + * let projection = map.getProjection(); + * ``` + */ + getProjection(): ProjectionSpecification { return this.style.getProjection(); } + + /** + * Sets the {@link ProjectionSpecification}. + * @param projection - the projection specification to set + * @returns + */ + setProjection(projection: ProjectionSpecification) { + this._lazyInitEmptyStyle(); + this.style.setProjection(projection); + return this._update(true); + } } diff --git a/src/ui/map_tests/map_events.test.ts b/src/ui/map_tests/map_events.test.ts index a17fd673be..9ec2a26039 100644 --- a/src/ui/map_tests/map_events.test.ts +++ b/src/ui/map_tests/map_events.test.ts @@ -1,10 +1,11 @@ import simulate from '../../../test/unit/lib/simulate_interaction'; import {StyleLayer} from '../../style/style_layer'; -import {createMap, beforeMapTest, createStyle} from '../../util/test/util'; +import {createMap, beforeMapTest, createStyle, sleep} from '../../util/test/util'; import {MapGeoJSONFeature} from '../../util/vectortile_to_geojson'; import {MapLayerEventType, MapLibreEvent} from '../events'; import {Map, MapOptions} from '../map'; import {Event as EventedEvent, ErrorEvent} from '../../util/evented'; +import {GlobeProjection} from '../../geo/projection/globe'; type IsAny = 0 extends T & 1 ? T : never; type NotAny = T extends IsAny ? never : T; @@ -998,4 +999,53 @@ describe('map events', () => { })); }); + + describe('projectiontransition event', () => { + test('projectiontransition events is fired when setProjection is called', async () => { + const map = createMap(); + + await map.once('load'); + + const spy = jest.fn(); + map.on('projectiontransition', (e) => spy(e.newProjection)); + map.setProjection({ + type: 'globe', + }); + map.setProjection({ + type: 'mercator', + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenNthCalledWith(1, 'globe'); + expect(spy).toHaveBeenNthCalledWith(2, 'mercator'); + }); + test('projectiontransition is fired when globe transitions to mercator', async () => { + const map = createMap(); + jest.spyOn(GlobeProjection.prototype, 'updateGPUdependent').mockImplementation(() => {}); + await map.once('load'); + + const spy = jest.fn(); + map.on('projectiontransition', (e) => spy(e.newProjection)); + + map.setProjection({ + type: 'globe', + }); + map.setZoom(18); + map.redraw(); + await sleep(550); + map.redraw(); + map.setZoom(0); + map.redraw(); + await sleep(550); + map.redraw(); + map.setProjection({ + type: 'mercator', + }); + + expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenNthCalledWith(1, 'globe'); + expect(spy).toHaveBeenNthCalledWith(2, 'globe-mercator'); + expect(spy).toHaveBeenNthCalledWith(3, 'globe'); + expect(spy).toHaveBeenNthCalledWith(4, 'mercator'); + }); + }); }); diff --git a/src/ui/map_tests/map_resize.test.ts b/src/ui/map_tests/map_resize.test.ts index baef486d71..4dcaec2bce 100644 --- a/src/ui/map_tests/map_resize.test.ts +++ b/src/ui/map_tests/map_resize.test.ts @@ -1,3 +1,4 @@ +import {MercatorProjection} from '../../geo/projection/mercator'; import {createMap, beforeMapTest, sleep} from '../../util/test/util'; beforeEach(() => { @@ -71,7 +72,7 @@ describe('#resize', () => { })); const map = createMap(); - + map.style.projection = new MercatorProjection(); const resizeSpy = jest.spyOn(map, 'resize'); const redrawSpy = jest.spyOn(map, 'redraw'); const renderSpy = jest.spyOn(map, '_render'); diff --git a/src/ui/map_tests/map_sky.test.ts b/src/ui/map_tests/map_sky.test.ts new file mode 100644 index 0000000000..9d468873e3 --- /dev/null +++ b/src/ui/map_tests/map_sky.test.ts @@ -0,0 +1,42 @@ +import {createMap, beforeMapTest} from '../../util/test/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +describe('#setSky', () => { + test('calls style setSky when set', () => { + const map = createMap(); + const spy = jest.fn(); + map.style.setSky = spy; + map.setSky({'atmosphere-blend': 0.5}); + + expect(spy).toHaveBeenCalled(); + }); +}); + +describe('#getSky', () => { + test('returns undefined when not set', () => { + const map = createMap(); + expect(map.getSky()).toBeUndefined(); + }); + + test('calls style getSky when invoked', () => { + const map = createMap(); + const spy = jest.fn(); + map.style.getSky = spy; + map.getSky(); + + expect(spy).toHaveBeenCalled(); + }); + + test('return previous style when set', async () => { + const map = createMap(); + await map.once('style.load'); + map.setSky({'atmosphere-blend': 0.5}); + + expect(map.getSky()).toEqual({'atmosphere-blend': 0.5}); + }); + +}); diff --git a/src/ui/map_tests/map_style.test.ts b/src/ui/map_tests/map_style.test.ts index 748d93093e..b60c686b51 100644 --- a/src/ui/map_tests/map_style.test.ts +++ b/src/ui/map_tests/map_style.test.ts @@ -6,6 +6,7 @@ import {extend} from '../../util/util'; import {fakeServer, FakeServer} from 'nise'; import {Style} from '../../style/style'; import {GeoJSONSourceSpecification, LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {LngLatBounds} from '../../geo/lng_lat_bounds'; let server: FakeServer; @@ -113,10 +114,9 @@ describe('#setStyle', () => { test('style transform overrides unmodified map transform', () => new Promise(done => { const map = new Map({container: window.document.createElement('div')} as any as MapOptions); - map.transform.lngRange = [-120, 140]; - map.transform.latRange = [-60, 80]; + map.transform.setMaxBounds(new LngLatBounds([-120, -60], [140, 80])); map.transform.resize(600, 400); - expect(map.transform.zoom).toBe(0.6983039737971014); + expect(map.transform.zoom).toBe(0.6983039737971013); expect(map.transform.unmodified).toBeTruthy(); map.setStyle(createStyle()); map.on('style.load', () => { diff --git a/src/ui/map_tests/map_terrian.test.ts b/src/ui/map_tests/map_terrian.test.ts index 077a127d0b..9e6bedceba 100644 --- a/src/ui/map_tests/map_terrian.test.ts +++ b/src/ui/map_tests/map_terrian.test.ts @@ -2,7 +2,7 @@ import {createMap, beforeMapTest} from '../../util/test/util'; import {LngLat} from '../../geo/lng_lat'; import {fakeServer, FakeServer} from 'nise'; import {Terrain} from '../../render/terrain'; -import {Transform} from '../../geo/transform'; +import {MercatorTransform} from '../../geo/projection/mercator_transform'; let server: FakeServer; @@ -58,12 +58,12 @@ describe('getCameraTargetElevation', () => { const terrainStub = {} as Terrain; map.terrain = terrainStub; - const transform = new Transform(0, 22, 0, 60, true); - transform.elevation = 200; - transform.center = new LngLat(10.0, 50.0); - transform.zoom = 14; + const transform = new MercatorTransform(0, 22, 0, 60, true); + transform.setElevation(200); + transform.setCenter(new LngLat(10.0, 50.0)); + transform.setZoom(14); transform.resize(512, 512); - transform.elevation = 2000; + transform.setElevation(2000); map.transform = transform; expect(map.getCameraTargetElevation()).toBe(2000); @@ -84,8 +84,9 @@ describe('Keep camera outside terrain', () => { // Terrain elevation is 10 everywhere, we are above it at zoom level 15 // with pitch 45 deg. map.jumpTo({center: [0.0, 0.0], bearing: 0, pitch: 45, zoom: 15}); - const initialCamPosition = map.transform.getCameraPosition(); - expect(initialCamPosition.altitude).toBeCloseTo(506, 0); + const initialLngLat = map.transform.screenPointToLocation(map.transform.getCameraPoint()); + const initialAltitude = map.transform.getCameraAltitude(); + expect(initialAltitude).toBeCloseTo(506, 0); // Now we set the elevation to 5000 everywhere and try to jump to the // same position. This would lead to a jump into the terrain, which @@ -95,10 +96,11 @@ describe('Keep camera outside terrain', () => { terrainElevation = 5000; map.jumpTo({center: [0.0, 0.0], pitch: 45, zoom: 15}); - expect(map.transform.getCameraPosition().lngLat.lng).toBeCloseTo(initialCamPosition.lngLat.lng); - expect(map.transform.getCameraPosition().lngLat.lat).toBeCloseTo(initialCamPosition.lngLat.lat); + const lngLat = map.transform.screenPointToLocation(map.transform.getCameraPoint()); + expect(lngLat.lng).toBeCloseTo(initialLngLat.lng); + expect(lngLat.lat).toBeCloseTo(initialLngLat.lat); expect(map.transform.pitch).toBeLessThan(45); - expect(map.transform.getCameraPosition().altitude).toBeGreaterThan(initialCamPosition.altitude); - expect(map.transform.getCameraPosition().altitude).toBeGreaterThan(terrainElevation); + expect(map.transform.getCameraAltitude()).toBeGreaterThan(initialAltitude); + expect(map.transform.getCameraAltitude()).toBeGreaterThan(terrainElevation); }); }); diff --git a/src/ui/marker.test.ts b/src/ui/marker.test.ts index 105aa8b82f..dec4482a1b 100644 --- a/src/ui/marker.test.ts +++ b/src/ui/marker.test.ts @@ -2,6 +2,7 @@ import {createMap as globalCreateMap, beforeMapTest, sleep} from '../util/test/u import {Marker} from './marker'; import {Popup} from './popup'; import {LngLat} from '../geo/lng_lat'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; import Point from '@mapbox/point-geometry'; import simulate from '../../test/unit/lib/simulate_interaction'; import type {Terrain} from '../render/terrain'; @@ -943,7 +944,7 @@ describe('marker', () => { test('Marker changes opacity behind terrain and when terrain is removed', async () => { const map = createMap(); - map.transform.lngLatToCameraDepth = () => .95; // Mocking distance to marker + jest.spyOn(MercatorTransform.prototype, 'lngLatToCameraDepth').mockImplementation((_lngLat, _ele) => 0.95); // Mocking distance to marker const marker = new Marker() .setLngLat([0, 0]) .addTo(map); @@ -978,7 +979,7 @@ describe('marker', () => { test('Applies options.opacity when 3d terrain is enabled and marker is in clear view', async () => { const map = createMap(); - map.transform.lngLatToCameraDepth = () => .95; // Mocking distance to marker + jest.spyOn(MercatorTransform.prototype, 'lngLatToCameraDepth').mockImplementation((_lngLat, _ele) => 0.95); // Mocking distance to marker const marker = new Marker({opacity: '0.7'}) .setLngLat([0, 0]) .addTo(map); @@ -996,7 +997,7 @@ describe('marker', () => { test('Applies options.opacity when marker\'s base is hidden by 3d terrain but its center is visible', async () => { const map = createMap(); - map.transform.lngLatToCameraDepth = () => .95; // Mocking distance to marker + jest.spyOn(MercatorTransform.prototype, 'lngLatToCameraDepth').mockImplementation((_lngLat, _ele) => 0.95); // Mocking distance to marker const marker = new Marker({opacity: '0.7'}) .setLngLat([0, 0]) .addTo(map); diff --git a/src/ui/marker.ts b/src/ui/marker.ts index dbc178daff..2417f4b13d 100644 --- a/src/ui/marker.ts +++ b/src/ui/marker.ts @@ -322,6 +322,7 @@ export class Marker extends Evented { map.on('move', this._update); map.on('moveend', this._update); map.on('terrain', this._update); + map.on('projectiontransition', this._update); this.setDraggable(this._draggable); this._update(); @@ -352,6 +353,7 @@ export class Marker extends Evented { this._map.off('move', this._update); this._map.off('moveend', this._update); this._map.off('terrain', this._update); + this._map.off('projectiontransition', this._update); this._map.off('mousedown', this._addDragHandler); this._map.off('touchstart', this._addDragHandler); this._map.off('mouseup', this._onUp); @@ -550,7 +552,9 @@ export class Marker extends Evented { _updateOpacity(force: boolean = false) { const terrain = this._map?.terrain; if (!terrain) { - if (this._element.style.opacity !== this._opacity) { this._element.style.opacity = this._opacity; } + const occluded = this._map.transform.isLocationOccluded(this._lngLat); + const targetOpacity = occluded ? this._opacityWhenCovered : this._opacity; + if (this._element.style.opacity !== targetOpacity) { this._element.style.opacity = targetOpacity; } return; } if (force) { @@ -576,7 +580,7 @@ export class Marker extends Evented { return; } // If the base is obscured, use the offset to check if the marker's center is obscured. - const metersToCenter = -this._offset.y / map.transform._pixelPerMeter; + const metersToCenter = -this._offset.y / map.transform.pixelsPerMeter; const elevationToCenter = Math.sin(map.getPitch() * Math.PI / 180) * metersToCenter; const terrainDistanceCenter = map.terrain.depthAtPoint(new Point(this._pos.x, this._pos.y - this._offset.y)); const markerDistanceCenter = map.transform.lngLatToCameraDepth(this._lngLat, elevation + elevationToCenter); @@ -604,7 +608,7 @@ export class Marker extends Evented { this._flatPos = this._pos = this._map.project(this._lngLat)._add(this._offset); if (this._map.terrain) { // flat position is saved because smartWrap needs non-elevated points - this._flatPos = this._map.transform.locationPoint(this._lngLat)._add(this._offset); + this._flatPos = this._map.transform.locationToScreenPoint(this._lngLat)._add(this._offset); } let rotation = ''; diff --git a/src/ui/popup.ts b/src/ui/popup.ts index c8f7125f09..8f3b450485 100644 --- a/src/ui/popup.ts +++ b/src/ui/popup.ts @@ -606,7 +606,7 @@ export class Popup extends Evented { const pos = this._flatPos = this._pos = this._trackPointer && cursor ? cursor : this._map.project(this._lngLat); if (this._map.terrain) { // flat position is saved because smartWrap needs non-elevated points - this._flatPos = this._trackPointer && cursor ? cursor : this._map.transform.locationPoint(this._lngLat); + this._flatPos = this._trackPointer && cursor ? cursor : this._map.transform.locationToScreenPoint(this._lngLat); } let anchor = this.options.anchor; diff --git a/src/util/create_tile_mesh.ts b/src/util/create_tile_mesh.ts new file mode 100644 index 0000000000..98b94b6351 --- /dev/null +++ b/src/util/create_tile_mesh.ts @@ -0,0 +1,208 @@ +import {Context} from '../gl/context'; +import {Mesh} from '../render/mesh'; +import {PosArray, TriangleIndexArray} from '../data/array_types.g'; +import {SegmentVector} from '../data/segment'; +import {NORTH_POLE_Y, SOUTH_POLE_Y} from '../render/subdivision'; +import {EXTENT} from '../data/extent'; +import posAttributes from '../data/pos_attributes'; + +const EXTENT_STENCIL_BORDER = EXTENT / 128; + +/** + * Options for generating a tile mesh. + * Can optionally configure any of the following: + * - mesh subdivision granularity + * - border presence + * - special geometry for the north and/or south pole + */ +export type CreateTileMeshOptions = { + /** + * Specifies how much should the tile mesh be subdivided. + * A value of 1 leads to a simple quad, a value of 4 will result in a grid of 4x4 quads. + */ + granularity?: number; + /** + * When true, an additional ring of quads is generated along the border, always extending `EXTENT_STENCIL_BORDER` units away from the main mesh. + */ + generateBorders?: boolean; + /** + * When true, additional geometry is generated along the north edge of the mesh, connecting it to the pole special vertex position. + * This geometry replaces the mesh border along this edge, if one is present. + */ + extendToNorthPole?: boolean; + /** + * When true, additional geometry is generated along the south edge of the mesh, connecting it to the pole special vertex position. + * This geometry replaces the mesh border along this edge, if one is present. + */ + extendToSouthPole?: boolean; +}; + +/** + * Stores the prepared vertex and index buffer bytes for a mesh. + */ +export type TileMesh = { + /** + * The vertex data. Each vertex is two 16 bit signed integers, one for X, one for Y. + */ + vertices: Int16Array; + /** + * The index data. Each triangle is defined by three indices. The indices may either be 16 bit or 32 bit unsigned integers, + * depending on the mesh creation arguments and on whether the mesh can fit into 16 bit indices. + */ + indices: T; + /** + * A helper boolean indicating whether the indices are 32 bit. + */ + uses32bitIndices: T extends Uint32Array ? true : false; +}; + +/** + * Describes desired type of vertex indices, either 16 bit uint, 32 bit uint, or, if undefined, any of the two options. + */ +export type IndicesType = '32bit' | '16bit' | undefined; + +/** + * @internal + * Creates a mesh of a quad that covers the entire tile (covering positions in range 0..EXTENT), + * is optionally subdivided into finer quads, optionally includes a border + * and optionally extends to the north and/or special pole vertices. + * Also allocates and populates WebGL buffers for the mesh. + * Forces 16 bit indices that are used throughout MapLibre. + * @param context - The WebGL context wrapper. + * @param options - Specify options for tile mesh creation such as granularity or border. + * @returns The mesh vertices and indices, already allocated and uploaded into WebGL buffers. + */ +export function createTileMeshWithBuffers(context: Context, options: CreateTileMeshOptions): Mesh { + const tileMesh = createTileMesh(options, '16bit'); + const vertices = PosArray.deserialize({ + arrayBuffer: tileMesh.vertices, + length: tileMesh.vertices.length / 2, // Two values per vertex + }); + const indices = TriangleIndexArray.deserialize({ + arrayBuffer: tileMesh.indices, + length: tileMesh.indices.length / 3, // Three values per triangle + }); + const mesh = new Mesh( + context.createVertexBuffer(vertices, posAttributes.members), + context.createIndexBuffer(indices), + SegmentVector.simpleSegment(0, 0, vertices.length, indices.length) + ); + + return mesh; +} + +/** + * Creates a mesh of a quad that covers the entire tile (covering positions in range 0..EXTENT), + * is optionally subdivided into finer quads, optionally includes a border + * and optionally extends to the north and/or special pole vertices. + * Additionally the resulting mesh indices type can be specified using `forceIndicesSize`. + * @example + * ``` + * // Creating a mesh for a tile that can be used for raster layers, hillshade, etc. + * const meshBuffers = createTileMesh({ + * granularity: map.style.projection.subdivisionGranularity.tile.getGranularityForZoomLevel(tileID.z), + * generateBorders: true, + * extendToNorthPole: tileID.y === 0, + * extendToSouthPole: tileID.y === (1 << tileID.z) - 1, + * }, '16bit'); + * ``` + * @param options - Specify options for tile mesh creation such as granularity or border. + * @param forceIndicesSize - Specifies what indices type to use. The values '32bit' and '16bit' force their respective indices size. If undefined, the mesh may use either size, and will pick 16 bit indices if possible. If '16bit' is specified and the mesh exceeds 65536 vertices, an exception is thrown. + * @returns Typed arrays of the mesh vertices and indices. + */ +export function createTileMesh(options: CreateTileMeshOptions, forceIndicesSize?: T): T extends '32bit' ? TileMesh : (T extends '16bit' ? TileMesh : TileMesh) { + // We only want to generate the north/south border if the tile + // does NOT border the north/south edge of the mercator range. + const granularity = options.granularity !== undefined ? Math.max(options.granularity, 1) : 1; + + const quadsPerAxisX = granularity + (options.generateBorders ? 2 : 0); // two extra quads for border + const quadsPerAxisY = granularity + ((options.extendToNorthPole || options.generateBorders) ? 1 : 0) + (options.extendToSouthPole || options.generateBorders ? 1 : 0); + const verticesPerAxisX = quadsPerAxisX + 1; // one more vertex than quads + const verticesPerAxisY = quadsPerAxisY + 1; // one more vertex than quads + const offsetX = options.generateBorders ? -1 : 0; + const offsetY = (options.generateBorders || options.extendToNorthPole) ? -1 : 0; + const endX = granularity + (options.generateBorders ? 1 : 0); + const endY = granularity + ((options.generateBorders || options.extendToSouthPole) ? 1 : 0); + + const vertexCount = verticesPerAxisX * verticesPerAxisY; + const indexCount = quadsPerAxisX * quadsPerAxisY * 6; + + const overflows16bitIndices = verticesPerAxisX * verticesPerAxisY > (1 << 16); + + if (overflows16bitIndices && forceIndicesSize === '16bit') { + throw new Error('Granularity is too large and meshes would not fit inside 16 bit vertex indices.'); + } + + const use32bitIndices = overflows16bitIndices || forceIndicesSize === '32bit'; + + const vertexArray = new Int16Array(vertexCount * 2); + + let resultMesh; + + if (use32bitIndices) { + const mesh: TileMesh = { + vertices: vertexArray, + indices: new Uint32Array(indexCount), + uses32bitIndices: true, + }; + resultMesh = mesh; + } else { + const mesh: TileMesh = { + vertices: vertexArray, + indices: new Uint16Array(indexCount), + uses32bitIndices: false, + }; + resultMesh = mesh; + } + + const indexArray = resultMesh.indices; + + let vertexId = 0; + + 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 = options.extendToNorthPole ? NORTH_POLE_Y : (-EXTENT_STENCIL_BORDER); + } + if (y === granularity + 1) { + vy = options.extendToSouthPole ? SOUTH_POLE_Y : EXTENT + EXTENT_STENCIL_BORDER; + } + + vertexArray[vertexId++] = vx; + vertexArray[vertexId++] = vy; + } + } + + let indexId = 0; + + 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[indexId++] = v0; + indexArray[indexId++] = v2; + indexArray[indexId++] = v1; + + indexArray[indexId++] = v1; + indexArray[indexId++] = v2; + indexArray[indexId++] = v3; + } + } + + return resultMesh; +} diff --git a/src/util/primitives.test.ts b/src/util/primitives.test.ts index aa6c40b752..e1ce2b2fbb 100644 --- a/src/util/primitives.test.ts +++ b/src/util/primitives.test.ts @@ -1,4 +1,4 @@ -import {Aabb, Frustum} from './primitives'; +import {Aabb, Frustum, IntersectionResult} from './primitives'; import {mat4, vec3, vec4} from 'gl-matrix'; describe('primitives', () => { @@ -68,7 +68,7 @@ describe('primitives', () => { ]; for (const aabb of aabbList) - expect(aabb.intersects(frustum)).toBe(2); + expect(aabb.intersectsFrustum(frustum)).toBe(IntersectionResult.Full); }); @@ -81,7 +81,7 @@ describe('primitives', () => { ]; for (const aabb of aabbList) - expect(aabb.intersects(frustum)).toBe(1); + expect(aabb.intersectsFrustum(frustum)).toBe(IntersectionResult.Partial); }); @@ -95,7 +95,7 @@ describe('primitives', () => { ]; for (const aabb of aabbList) - expect(aabb.intersects(frustum)).toBe(0); + expect(aabb.intersectsFrustum(frustum)).toBe(IntersectionResult.None); }); diff --git a/src/util/primitives.ts b/src/util/primitives.ts index 6f6f60113c..b96b66fc6f 100644 --- a/src/util/primitives.ts +++ b/src/util/primitives.ts @@ -1,10 +1,16 @@ import {mat4, vec3, vec4} from 'gl-matrix'; -class Frustum { +export const enum IntersectionResult { + None = 0, + Partial = 1, + Full = 2, +} + +export class Frustum { constructor(public points: vec4[], public planes: vec4[]) { } - public static fromInvProjectionMatrix(invProj: mat4, worldSize: number, zoom: number): Frustum { + public static fromInvProjectionMatrix(invProj: mat4, worldSize: number = 1, zoom: number = 0): Frustum { const clipSpaceCorners = [ [-1, 1, -1, 1], [1, 1, -1, 1], @@ -46,7 +52,7 @@ class Frustum { } } -class Aabb { +export class Aabb { min: vec3; max: vec3; center: vec3; @@ -80,13 +86,15 @@ class Aabb { return pointOnAabb - point[1]; } - // Performs a frustum-aabb intersection test. Returns 0 if there's no intersection, - // 1 if shapes are intersecting and 2 if the aabb if fully inside the frustum. - intersects(frustum: Frustum): number { + /** + * Performs a frustum-aabb intersection test. Returns 0 if there's no intersection, + * 1 if shapes are intersecting and 2 if the aabb if fully inside the frustum. + */ + intersectsFrustum(frustum: Frustum): IntersectionResult { // Execute separating axis test between two convex objects to find intersections // Each frustum plane together with 3 major axes define the separating axes - const aabbPoints = [ + const aabbPoints: Array = [ [this.min[0], this.min[1], this.min[2], 1], [this.max[0], this.min[1], this.min[2], 1], [this.max[0], this.max[1], this.min[2], 1], @@ -104,20 +112,20 @@ class Aabb { let pointsInside = 0; for (let i = 0; i < aabbPoints.length; i++) { - if (vec4.dot(plane, aabbPoints[i] as any) >= 0) { + if (vec4.dot(plane, aabbPoints[i]) >= 0) { pointsInside++; } } if (pointsInside === 0) - return 0; + return IntersectionResult.None; if (pointsInside !== aabbPoints.length) fullyInside = false; } if (fullyInside) - return 2; + return IntersectionResult.Full; for (let axis = 0; axis < 3; axis++) { let projMin = Number.MAX_VALUE; @@ -131,13 +139,41 @@ class Aabb { } if (projMax < 0 || projMin > this.max[axis] - this.min[axis]) - return 0; + return IntersectionResult.None; + } + + return IntersectionResult.Partial; + } + + /** + * Performs a halfspace-aabb intersection test. Returns 0 if there's no intersection, + * 1 if shapes are intersecting and 2 if the aabb if fully inside the plane's positive halfspace. + */ + intersectsPlane(plane: vec4): IntersectionResult { + const aabbPoints: Array = [ + [this.min[0], this.min[1], this.min[2], 1], + [this.max[0], this.min[1], this.min[2], 1], + [this.max[0], this.max[1], this.min[2], 1], + [this.min[0], this.max[1], this.min[2], 1], + [this.min[0], this.min[1], this.max[2], 1], + [this.max[0], this.min[1], this.max[2], 1], + [this.max[0], this.max[1], this.max[2], 1], + [this.min[0], this.max[1], this.max[2], 1] + ]; + + let pointsInside = 0; + for (let i = 0; i < aabbPoints.length; i++) { + if (vec4.dot(plane, aabbPoints[i]) >= 0) { + pointsInside++; + } } - return 1; + if (pointsInside === 0) { + return IntersectionResult.None; + } else if (pointsInside < aabbPoints.length) { + return IntersectionResult.Partial; + } else { + return IntersectionResult.Full; + } } } -export { - Aabb, - Frustum -}; diff --git a/src/util/smart_wrap.test.ts b/src/util/smart_wrap.test.ts index 44c8ef230d..e93fe9d7d7 100644 --- a/src/util/smart_wrap.test.ts +++ b/src/util/smart_wrap.test.ts @@ -1,16 +1,15 @@ import Point from '@mapbox/point-geometry'; import {LngLat} from '../geo/lng_lat'; -import {Transform} from '../geo/transform'; import {smartWrap} from './smart_wrap'; +import {MercatorTransform} from '../geo/projection/mercator_transform'; -const transform = new Transform(); -transform.width = 100; -transform.height = 100; -transform.getHorizon = () => 0; // map center +const transform = new MercatorTransform(); +transform.resize(100, 100); +transform.isPointOnMapSurface = (p) => p.y > transform.height / 2; // any point below map center is considered to be on the map's surface describe('smartWrap', () => { test('Shifts lng to -360 deg when such point is closer to the priorPos', () => { - transform.locationPoint = ((ll: LngLat) => { + transform.locationToScreenPoint = ((ll: LngLat) => { if (ll.lng === -360) { return new Point(90, 51); // close to priorPos & below horizon } else { @@ -26,7 +25,7 @@ describe('smartWrap', () => { }); test('Shifts lng to +360 deg when such point is closer to the priorPos', () => { - transform.locationPoint = ((ll: LngLat) => { + transform.locationToScreenPoint = ((ll: LngLat) => { if (ll.lng === 360) { return new Point(90, 51); // close to priorPos & below horizon } else { @@ -42,7 +41,7 @@ describe('smartWrap', () => { }); test('Does not change lng when there are no closer points at -360 and +360 deg', () => { - transform.locationPoint = ((ll: LngLat) => { + transform.locationToScreenPoint = ((ll: LngLat) => { if (ll.lng === 15) { return new Point(90, 51); // close to priorPos & below horizon } else { @@ -58,7 +57,7 @@ describe('smartWrap', () => { }); test('Does not change lng to -360 deg when such point is above horizon', () => { - transform.locationPoint = ((ll: LngLat) => { + transform.locationToScreenPoint = ((ll: LngLat) => { if (ll.lng === -360) { return new Point(90, 49); // close to priorPos BUT above horizon } else { @@ -75,7 +74,7 @@ describe('smartWrap', () => { test('Shifts lng to -360 if lng is outside viewport on the right and at least 180° from map center', () => { transform.center.lng = 50; - transform.locationPoint = (() => { return new Point(110, 51); }); // outside viewport + transform.locationToScreenPoint = (() => { return new Point(110, 51); }); // outside viewport const result = smartWrap( new LngLat(250, 0), // 200 from map center @@ -86,7 +85,7 @@ describe('smartWrap', () => { test('Shifts lng to +360 if lng is outside viewport on the left and at least 180° from map center', () => { transform.center.lng = 50; - transform.locationPoint = (() => { return new Point(-10, 51); }); // outside viewport + transform.locationToScreenPoint = (() => { return new Point(-10, 51); }); // outside viewport const result = smartWrap( new LngLat(-150, 0), // 200 from map center diff --git a/src/util/smart_wrap.ts b/src/util/smart_wrap.ts index 2ef487e193..6e5f2fa89f 100644 --- a/src/util/smart_wrap.ts +++ b/src/util/smart_wrap.ts @@ -1,7 +1,7 @@ import {LngLat} from '../geo/lng_lat'; import type Point from '@mapbox/point-geometry'; -import type {Transform} from '../geo/transform'; +import type {IReadonlyTransform} from '../geo/transform_interface'; /** * Given a LngLat, prior projected position, and a transform, return a new LngLat shifted @@ -16,7 +16,7 @@ import type {Transform} from '../geo/transform'; * map center changes by ±360° due to automatic wrapping, and when about to go off screen, * should wrap just enough to avoid doing so. */ -export function smartWrap(lngLat: LngLat, priorPos: Point, transform: Transform): LngLat { +export function smartWrap(lngLat: LngLat, priorPos: Point, transform: IReadonlyTransform): LngLat { const originalLngLat = new LngLat(lngLat.lng, lngLat.lat); lngLat = new LngLat(lngLat.lng, lngLat.lat); @@ -26,10 +26,10 @@ export function smartWrap(lngLat: LngLat, priorPos: Point, transform: Transform) if (priorPos) { const left = new LngLat(lngLat.lng - 360, lngLat.lat); const right = new LngLat(lngLat.lng + 360, lngLat.lat); - const delta = transform.locationPoint(lngLat).distSqr(priorPos); - if (transform.locationPoint(left).distSqr(priorPos) < delta) { + const delta = transform.locationToScreenPoint(lngLat).distSqr(priorPos); + if (transform.locationToScreenPoint(left).distSqr(priorPos) < delta) { lngLat = left; - } else if (transform.locationPoint(right).distSqr(priorPos) < delta) { + } else if (transform.locationToScreenPoint(right).distSqr(priorPos) < delta) { lngLat = right; } } @@ -37,7 +37,7 @@ export function smartWrap(lngLat: LngLat, priorPos: Point, transform: Transform) // Second, wrap toward the center until the new position is on screen, or we can't get // any closer. while (Math.abs(lngLat.lng - transform.center.lng) > 180) { - const pos = transform.locationPoint(lngLat); + const pos = transform.locationToScreenPoint(lngLat); if (pos.x >= 0 && pos.y >= 0 && pos.x <= transform.width && pos.y <= transform.height) { break; } @@ -49,8 +49,7 @@ export function smartWrap(lngLat: LngLat, priorPos: Point, transform: Transform) } // Apply the change only if new coord is below horizon - if (lngLat.lng !== originalLngLat.lng && - transform.locationPoint(lngLat).y > (transform.height / 2 - transform.getHorizon())) { + if (lngLat.lng !== originalLngLat.lng && transform.isPointOnMapSurface(transform.locationToScreenPoint(lngLat))) { return lngLat; } diff --git a/src/util/test/util.ts b/src/util/test/util.ts index c6cc332026..a009e1b2ff 100644 --- a/src/util/test/util.ts +++ b/src/util/test/util.ts @@ -2,8 +2,42 @@ import {Map} from '../../ui/map'; import {extend} from '../../util/util'; import {Dispatcher} from '../../util/dispatcher'; import {IActor} from '../actor'; -import type {Evented} from '../evented'; -import {SourceSpecification, StyleSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {Evented} from '../evented'; +import {SourceSpecification, StyleSpecification, TerrainSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {MercatorTransform} from '../../geo/projection/mercator_transform'; +import {RequestManager} from '../request_manager'; +import {IReadonlyTransform, ITransform} from '../../geo/transform_interface'; +import {Style} from '../../style/style'; +import type {GlobeProjection} from '../../geo/projection/globe'; + +export class StubMap extends Evented { + style: Style; + transform: IReadonlyTransform; + private _requestManager: RequestManager; + _terrain: TerrainSpecification; + + constructor() { + super(); + this.transform = new MercatorTransform(); + this._requestManager = new RequestManager(); + } + + _getMapId() { + return 1; + } + + getPixelRatio() { + return 1; + } + + setTerrain(terrain) { this._terrain = terrain; } + getTerrain() { return this._terrain; } + + migrateProjection(newTransform: ITransform) { + newTransform.apply(this.transform); + this.transform = newTransform; + } +} export function createMap(options?, callback?) { const container = window.document.createElement('div'); @@ -183,3 +217,26 @@ export function createStyle(): StyleSpecification { layers: [] }; } + +export function expectToBeCloseToArray(actual: Array, expected: Array, precision?: number) { + expect(actual).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(actual[i]).toBeCloseTo(expected[i], precision); + } +} + +export function getGlobeProjectionMock(): GlobeProjection { + return { + get useGlobeControls(): boolean { + return true; + }, + get useGlobeRendering(): boolean { + return true; + }, + set useGlobeRendering(_value: boolean) { + // do not set + }, + latitudeErrorCorrectionRadians: 0, + errorQueryLatitudeDegrees: 0, + } as GlobeProjection; +} diff --git a/src/util/util.test.ts b/src/util/util.test.ts index 22b9eac7a8..82a9e0de0c 100644 --- a/src/util/util.test.ts +++ b/src/util/util.test.ts @@ -1,5 +1,5 @@ import Point from '@mapbox/point-geometry'; -import {arraysIntersect, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap} from './util'; +import {arraysIntersect, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap, mod, distanceOfAnglesRadians, distanceOfAnglesDegrees, differenceOfAnglesRadians, differenceOfAnglesDegrees, solveQuadratic, remapSaturate} from './util'; import {Canvas} from 'canvas'; describe('util', () => { @@ -112,6 +112,59 @@ describe('util', () => { expect(deepEqual(null, c)).toBeFalsy(); expect(deepEqual(null, null)).toBeTruthy(); }); + + test('mod', () => { + expect(mod(2, 3)).toBe(2); + expect(mod(4, 3)).toBe(1); + expect(mod(-1, 3)).toBe(2); + expect(mod(-1, 3)).toBe(2); + }); + + test('distanceOfAnglesRadians', () => { + const digits = 10; + expect(distanceOfAnglesRadians(0, 1)).toBeCloseTo(1, digits); + expect(distanceOfAnglesRadians(0.1, 2 * Math.PI - 0.1)).toBeCloseTo(0.2, digits); + expect(distanceOfAnglesRadians(0.5, -0.5)).toBeCloseTo(1, digits); + expect(distanceOfAnglesRadians(-0.5, 0.5)).toBeCloseTo(1, digits); + }); + + test('distanceOfAnglesDegrees', () => { + const digits = 10; + expect(distanceOfAnglesDegrees(0, 1)).toBeCloseTo(1, digits); + expect(distanceOfAnglesDegrees(10, 350)).toBeCloseTo(20, digits); + expect(distanceOfAnglesDegrees(0.5, -0.5)).toBeCloseTo(1, digits); + expect(distanceOfAnglesDegrees(-0.5, 0.5)).toBeCloseTo(1, digits); + }); + + test('differenceOfAnglesRadians', () => { + const digits = 10; + expect(differenceOfAnglesRadians(0, 1)).toBeCloseTo(1, digits); + expect(differenceOfAnglesRadians(0, -1)).toBeCloseTo(-1, digits); + expect(differenceOfAnglesRadians(0.1, 2 * Math.PI - 0.1)).toBeCloseTo(-0.2, digits); + }); + + test('differenceOfAnglesDegrees', () => { + const digits = 10; + expect(differenceOfAnglesDegrees(0, 1)).toBeCloseTo(1, digits); + expect(differenceOfAnglesDegrees(0, -1)).toBeCloseTo(-1, digits); + expect(differenceOfAnglesDegrees(10, 350)).toBeCloseTo(-20, digits); + }); + + test('solveQuadratic', () => { + expect(solveQuadratic(0, 0, 0)).toBeNull(); + expect(solveQuadratic(1, 0, 1)).toBeNull(); + expect(solveQuadratic(1, 0, -1)).toEqual({t0: 1, t1: 1}); + expect(solveQuadratic(1, -8, 12)).toEqual({t0: 2, t1: 6}); + }); + + test('remapSaturate', () => { + expect(remapSaturate(0, 0, 1, 2, 3)).toBe(2); + expect(remapSaturate(1, 0, 2, 2, 3)).toBe(2.5); + expect(remapSaturate(999, 0, 2, 2, 3)).toBe(3); + expect(remapSaturate(1, 1, 0, 2, 3)).toBe(2); + expect(remapSaturate(1, 0, 1, 3, 2)).toBe(2); + expect(remapSaturate(1, 1, 0, 3, 2)).toBe(3); + }); }); describe('util clone', () => { diff --git a/src/util/util.ts b/src/util/util.ts index 8b79b8316e..ab0a4dc019 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -3,6 +3,219 @@ import UnitBezier from '@mapbox/unitbezier'; import {isOffscreenCanvasDistorted} from './offscreen_canvas_distorted'; import type {Size} from './image'; import type {WorkerGlobalScopeInterface} from './web_worker'; +import {mat4, vec3, vec4} from 'gl-matrix'; +import {pixelsToTileUnits} from '../source/pixels_to_tile_units'; +import {OverscaledTileID} from '../source/tile_id'; + +/** + * Returns a new 64 bit float vec4 of zeroes. + */ +export function createVec4f64(): vec4 { return new Float64Array(4) as any; } +/** + * Returns a new 64 bit float vec3 of zeroes. + */ +export function createVec3f64(): vec3 { return new Float64Array(3) as any; } +/** + * Returns a new 64 bit float mat4 of zeroes. + */ +export function createMat4f64(): mat4 { return new Float64Array(16) as any; } +/** + * Returns a new 64 bit float mat4 set to identity. + */ +export function createIdentityMat4f64(): mat4 { + const m = new Float64Array(16) as any; + mat4.identity(m); + return m; +} + +/** + * 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: { angle: number; zoom: number }, + tile: { tileID: OverscaledTileID; tileSize: number }, + 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)]; +} + +/** + * Returns the signed distance between a point and a plane. + * @param plane - The plane equation, in the form where the first three components are the normal and the fourth component is the plane's distance from origin along normal. + * @param point - The point whose distance from plane is returned. + * @returns Signed distance of the point from the plane. Positive distances are in the half space where the plane normal points to, negative otherwise. + */ +export function pointPlaneSignedDistance( + plane: vec4 | [number, number, number, number], + point: vec3 | [number, number, number] +): number { + return plane[0] * point[0] + plane[1] * point[1] + plane[2] * point[2] + plane[3]; +} + +/** + * Solves a quadratic equation in the form ax^2 + bx + c = 0 and returns its roots in no particular order. + * Returns null if the equation has no roots or if it has infinitely many roots. + */ +export function solveQuadratic(a: number, b: number, c: number): { + t0: number; + t1: number; +} { + const d = b * b - 4 * a * c; + if (d < 0 || (a === 0 && b === 0)) { + return null; + } + + // Uses a more precise solution from the book Ray Tracing Gems, chapter 7. + // https://www.realtimerendering.com/raytracinggems/rtg/index.html + const q = -0.5 * (b + Math.sign(b) * Math.sqrt(d)); + if (Math.abs(q) > 1e-12) { + return { + t0: c / q, + t1: q / a + }; + } else { + // Use the schoolbook way if q is too small + return { + t0: (-b + Math.sqrt(d)) * 0.5 / a, + t1: (-b + Math.sqrt(d)) * 0.5 / a + }; + } +} + +/** + * Returns the angle in radians between two 2D vectors. + * The angle is signed and describes how much the first vector would need to be be rotated clockwise + * (assuming X is right and Y is down) so that it points in the same direction as the second vector. + * @param vec1x - The X component of the first vector. + * @param vec1y - The Y component of the first vector. + * @param vec2x - The X component of the second vector. + * @param vec2y - The Y component of the second vector. + * @returns The signed angle between the two vectors, in range -PI..PI. + */ +export function angleToRotateBetweenVectors2D(vec1x: number, vec1y: number, vec2x: number, vec2y: number): number { + // Normalize both vectors + const length1 = Math.sqrt(vec1x * vec1x + vec1y * vec1y); + const length2 = Math.sqrt(vec2x * vec2x + vec2y * vec2y); + vec1x /= length1; + vec1y /= length1; + vec2x /= length2; + vec2y /= length2; + const dot = vec1x * vec2x + vec1y * vec2y; + const angle = Math.acos(dot); + // dot second vector with vector to the right of first (-vec1y, vec1x) + const isVec2RightOfVec1 = (-vec1y * vec2x + vec1x * vec2y) > 0; + if (isVec2RightOfVec1) { + return angle; + } else { + return -angle; + } +} + +/** + * For two angles in degrees, returns how many degrees to add to the first angle in order to obtain the second angle. + * The returned difference value is always the shorted of the two - its absolute value is never greater than 180°. + */ +export function differenceOfAnglesDegrees(degreesA: number, degreesB: number): number { + const a = mod(degreesA, 360); + const b = mod(degreesB, 360); + const diff1 = b - a; + const diff2 = (b > a) ? (diff1 - 360) : (diff1 + 360); + if (Math.abs(diff1) < Math.abs(diff2)) { + return diff1; + } else { + return diff2; + } +} + +/** + * For two angles in radians, returns how many radians to add to the first angle in order to obtain the second angle. + * The returned difference value is always the shorted of the two - its absolute value is never greater than PI. + */ +export function differenceOfAnglesRadians(degreesA: number, degreesB: number): number { + const a = mod(degreesA, Math.PI * 2); + const b = mod(degreesB, Math.PI * 2); + const diff1 = b - a; + const diff2 = (b > a) ? (diff1 - Math.PI * 2) : (diff1 + Math.PI * 2); + if (Math.abs(diff1) < Math.abs(diff2)) { + return diff1; + } else { + return diff2; + } +} + +/** + * When given two angles in degrees, returns the angular distance between them - the shorter one of the two possible arcs. + */ +export function distanceOfAnglesDegrees(degreesA: number, degreesB: number): number { + const a = mod(degreesA, 360); + const b = mod(degreesB, 360); + return Math.min( + Math.abs(a - b), + Math.abs(a - b + 360), + Math.abs(a - b - 360) + ); +} + +/** + * When given two angles in radians, returns the angular distance between them - the shorter one of the two possible arcs. + */ +export function distanceOfAnglesRadians(radiansA: number, radiansB: number): number { + const a = mod(radiansA, Math.PI * 2); + const b = mod(radiansB, Math.PI * 2); + return Math.min( + Math.abs(a - b), + Math.abs(a - b + Math.PI * 2), + Math.abs(a - b - Math.PI * 2) + ); +} + +/** + * Modulo function, as opposed to javascript's `%`, which is a remainder. + * This functions will return positive values, even if the first operand is negative. + */ +export function mod(n, m) { + return ((n % m) + m) % m; +} + +/** + * Takes a value in *old range*, linearly maps that range to *new range*, and returns the value in that new range. + * Additionally, if the value is outside *old range*, it is clamped inside it. + * Also works if one of the ranges is flipped (its `min` being larger than `max`). + */ +export function remapSaturate(value: number, oldRangeMin: number, oldRangeMax: number, newRangeMin: number, newRangeMax: number): number { + const inOldRange = clamp((value - oldRangeMin) / (oldRangeMax - oldRangeMin), 0.0, 1.0); + return lerp(newRangeMin, newRangeMax, inOldRange); +} + +/** + * 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; +} /** * For a given collection of 2D points, returns their axis-aligned bounding box, diff --git a/src/util/web_worker_transfer.test.ts b/src/util/web_worker_transfer.test.ts index 4969e0dc97..ce9d4db68e 100644 --- a/src/util/web_worker_transfer.test.ts +++ b/src/util/web_worker_transfer.test.ts @@ -1,6 +1,5 @@ -import {SerializedObject} from '../../dist/maplibre-gl'; import {AJAXError} from './ajax'; -import {register, serialize, deserialize} from './web_worker_transfer'; +import {register, serialize, deserialize, SerializedObject} from './web_worker_transfer'; describe('web worker transfer', () => { test('round trip', () => { diff --git a/src/util/web_worker_transfer.ts b/src/util/web_worker_transfer.ts index a8f1cd4325..3616156959 100644 --- a/src/util/web_worker_transfer.ts +++ b/src/util/web_worker_transfer.ts @@ -7,7 +7,7 @@ import {isImageBitmap} from './util'; /** * A class that is serialized to and json, that can be constructed back to the original class in the worker or in the main thread */ -type SerializedObject = { +export type SerializedObject = { [_: string]: S; }; diff --git a/test/bench/benchmarks/subdivide.ts b/test/bench/benchmarks/subdivide.ts new file mode 100644 index 0000000000..f914ee73d8 --- /dev/null +++ b/test/bench/benchmarks/subdivide.ts @@ -0,0 +1,63 @@ +import {CanonicalTileID} from '../../../src/source/tile_id'; +import Benchmark from '../lib/benchmark'; +import {EXTENT} from '../../../src/data/extent'; +import {subdividePolygon} from '../../../src/render/subdivision'; +import Point from '@mapbox/point-geometry'; + +export default class Subdivide extends Benchmark { + tileID: CanonicalTileID; + granularity: number; + polygon: Array>; + + async setup(): Promise { + await super.setup(); + + // Reasonably fast benchmark parameters: + // vertexCountMultiplier = 11 + // granularity = 64 + + const vertexCountMultiplier = 11; + this.granularity = 64; + + // Use web mercator base tile, as it borders both north and south poles, + // so we also benchmark pole geometry generation. + this.tileID = new CanonicalTileID(2, 1, 1); // tile avoids north and south mercator edges + + const polygon = []; + + polygon.push(generateRing(EXTENT / 2, EXTENT / 2, EXTENT * 1.1 / 2, 81 * vertexCountMultiplier)); + + // this function takes arguments in range 0..1, where 0 maps to 0 and 1 to EXTENT + // this makes placing holes by hand easier + function generateHole(cx: number, cy: number, r: number, vertexCount: number) { + polygon.push(generateRing(cx * EXTENT, cy * EXTENT, r * EXTENT, vertexCount)); + } + + generateHole(0.25, 0.5, 0.15, 16 * vertexCountMultiplier); + generateHole(0.75, 0.5, 0.15, 2 * vertexCountMultiplier); + generateHole(0.5, 0.1, 0.05, 4 * vertexCountMultiplier); + + this.polygon = polygon; + } + + bench() { + for (let i = 0; i < 10; i++) { + subdividePolygon(this.polygon, this.tileID, this.granularity, true); + } + } +} + +function generateRing(cx: number, cy: number, radius: number, vertexCount: number): Array { + const ring = []; + + for (let i = 0; i < vertexCount; i++) { + const angle = i / vertexCount * 2.0 * Math.PI; + // round to emulate integer vertex coordinates + ring.push(new Point( + Math.round(cx + Math.cos(angle) * radius), + Math.round(cy + Math.sin(angle) * radius) + )); + } + + return ring; +} diff --git a/test/bench/benchmarks/symbol_collision_box.ts b/test/bench/benchmarks/symbol_collision_box.ts index 23aa8a5ff0..630211a5f1 100644 --- a/test/bench/benchmarks/symbol_collision_box.ts +++ b/test/bench/benchmarks/symbol_collision_box.ts @@ -1,24 +1,24 @@ import Point from '@mapbox/point-geometry'; -import {Projection, createProjection} from '../../../src/geo/projection/projection'; -import {Transform} from '../../../src/geo/transform'; +import {ITransform} from '../../../src/geo/transform_interface'; import {CollisionIndex} from '../../../src/symbol/collision_index'; import Benchmark from '../lib/benchmark'; -import {mat4} from 'gl-matrix'; import {OverlapMode} from '../../../src/style/style_layer/overlap_mode'; import {CanonicalTileID, UnwrappedTileID} from '../../../src/source/tile_id'; import {SingleCollisionBox} from '../../../src/data/bucket/symbol_bucket'; import {EXTENT} from '../../../src/data/extent'; +import {MercatorTransform} from '../../../src/geo/projection/mercator_transform'; +import {mat4} from 'gl-matrix'; type TestSymbol = { collisionBox: SingleCollisionBox; overlapMode: OverlapMode; textPixelRatio: number; - posMatrix: mat4; unwrappedTileID: UnwrappedTileID; pitchWithMap: boolean; rotateWithMap: boolean; translation: [number, number]; shift?: Point; + simpleProjectionMatrix?: mat4; } // For this benchmark we need a deterministic random number generator. This function provides one. @@ -37,16 +37,14 @@ function splitmix32(a) { } export default class SymbolCollisionBox extends Benchmark { - private _transform: Transform; - private _projection: Projection; + private _transform: ITransform; private _symbols: Array; async setup(): Promise { - this._transform = new Transform(0, 22, 0, 60, true); - this._transform.resize(1024, 1024); - this._projection = createProjection(); + const tr = new MercatorTransform(0, 22, 0, 60, true); + this._transform = tr; + tr.resize(1024, 1024); const unwrappedTileID = new UnwrappedTileID(0, new CanonicalTileID(0, 0, 0)); - const posMatrix = this._transform.calculatePosMatrix(unwrappedTileID); const rng = splitmix32(0xdeadbeef); const rndRange = (min, max) => { @@ -68,7 +66,6 @@ export default class SymbolCollisionBox extends Benchmark { }, overlapMode: 'never', textPixelRatio: 1, - posMatrix, unwrappedTileID, pitchWithMap: rng() > 0.5, rotateWithMap: rng() > 0.5, @@ -76,13 +73,14 @@ export default class SymbolCollisionBox extends Benchmark { rndRange(-20, 20), rndRange(-20, 20) ], - shift: rng() > 0.5 ? new Point(rndRange(-20, 20), rndRange(-20, 20)) : undefined + shift: rng() > 0.5 ? new Point(rndRange(-20, 20), rndRange(-20, 20)) : undefined, + simpleProjectionMatrix: tr.calculatePosMatrix(unwrappedTileID, false), }); } } async bench() { - const ci = new CollisionIndex(this._transform, this._projection); + const ci = new CollisionIndex(this._transform); ci.grid.hitTest = (_x1, _y1, _x2, _y2, _overlapMode, _predicate?) => { return true; }; @@ -92,14 +90,14 @@ export default class SymbolCollisionBox extends Benchmark { s.collisionBox, s.overlapMode, s.textPixelRatio, - s.posMatrix, s.unwrappedTileID, s.pitchWithMap, s.rotateWithMap, s.translation, null, null, - s.shift + s.shift, + s.simpleProjectionMatrix, ); } } diff --git a/test/bench/benchmarks/symbol_layout.ts b/test/bench/benchmarks/symbol_layout.ts index 3eace902be..8d01ba4b00 100644 --- a/test/bench/benchmarks/symbol_layout.ts +++ b/test/bench/benchmarks/symbol_layout.ts @@ -2,6 +2,7 @@ import Layout from './layout'; import {SymbolBucket} from '../../../src/data/bucket/symbol_bucket'; import {performSymbolLayout} from '../../../src/symbol/symbol_layout'; import {OverscaledTileID} from '../../../src/source/tile_id'; +import {SubdivisionGranularitySetting} from '../../../src/render/subdivision_granularity_settings'; export default class SymbolLayout extends Layout { parsedTiles: Array; @@ -32,7 +33,8 @@ export default class SymbolLayout extends Layout { imageMap: tileResult.iconMap, imagePositions: tileResult.imageAtlas.iconPositions, showCollisionBoxes: false, - canonical: tileResult.featureIndex.tileID.canonical + canonical: tileResult.featureIndex.tileID.canonical, + subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision }); } } diff --git a/test/bench/lib/tile_parser.ts b/test/bench/lib/tile_parser.ts index 510f11a347..7128a22657 100644 --- a/test/bench/lib/tile_parser.ts +++ b/test/bench/lib/tile_parser.ts @@ -3,7 +3,7 @@ import VT from '@mapbox/vector-tile'; import {derefLayers as deref} from '@maplibre/maplibre-gl-style-spec'; import {Style} from '../../../src/style/style'; -import {Transform} from '../../../src/geo/transform'; +import {IReadonlyTransform} from '../../../src/geo/transform_interface'; import {Evented} from '../../../src/util/evented'; import {RequestManager} from '../../../src/util/request_manager'; import {WorkerTile} from '../../../src/source/worker_tile'; @@ -15,17 +15,19 @@ import type {OverscaledTileID} from '../../../src/source/tile_id'; import type {TileJSON} from '../../../src/util/util'; import type {Map} from '../../../src/ui/map'; import type {IActor} from '../../../src/util/actor'; +import {SubdivisionGranularitySetting} from '../../../src/render/subdivision_granularity_settings'; import {MessageType} from '../../../src/util/actor_messages'; +import {MercatorTransform} from '../../../src/geo/projection/mercator_transform'; class StubMap extends Evented { style: Style; _requestManager: RequestManager; - transform: Transform; + transform: IReadonlyTransform; constructor() { super(); this._requestManager = new RequestManager(); - this.transform = new Transform(); + this.transform = new MercatorTransform(); } getPixelRatio() { @@ -133,11 +135,12 @@ export default class TileParser { pixelRatio: 1, request: {url: ''}, returnDependencies, - promoteId: undefined + promoteId: undefined, + subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision }); const vectorTile = new VT.VectorTile(new Protobuf(tile.buffer)); - return workerTile.parse(vectorTile, this.layerIndex, [], this.actor); + return workerTile.parse(vectorTile, this.layerIndex, [], this.actor, SubdivisionGranularitySetting.noSubdivision); } } diff --git a/test/bench/versions/index.ts b/test/bench/versions/index.ts index 1f161a6d9d..34215dfb50 100644 --- a/test/bench/versions/index.ts +++ b/test/bench/versions/index.ts @@ -22,6 +22,7 @@ import MapIdle from '../benchmarks/map_idle'; import {getGlobalWorkerPool} from '../../../src/util/global_worker_pool'; import SymbolCollisionBox from '../benchmarks/symbol_collision_box'; +import Subdivide from '../benchmarks/subdivide'; const styleLocations = locationsWithTileID(styleBenchmarkLocations.features as GeoJSON.Feature[]).filter(v => v.zoom < 15); // the used maptiler sources have a maxzoom of 14 @@ -75,6 +76,7 @@ register('HillshadeLoad', new HillshadeLoad()); register('CustomLayer', new CustomLayer()); register('MapIdle', new MapIdle()); register('SymbolCollisionBox', new SymbolCollisionBox()); +register('Subdivide', new Subdivide()); Promise.resolve().then(() => { // Ensure the global worker pool is never drained. Browsers have resource limits diff --git a/test/build/min.test.ts b/test/build/min.test.ts index 5d2b7cbcc2..71272d2f14 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -1,5 +1,5 @@ import fs from 'fs'; -import packageJson from '../../package.json' with { type: 'json' }; +import packageJson from '../../package.json' assert {type: 'json'}; const minBundle = fs.readFileSync('dist/maplibre-gl.js', 'utf8'); @@ -36,9 +36,9 @@ 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 = 803130; + const expectedBytes = 877777; - expect(actualBytes - expectedBytes).toBeLessThan(increaseQuota); - expect(expectedBytes - actualBytes).toBeLessThan(decreaseQuota); + expect(actualBytes).toBeLessThan(expectedBytes + increaseQuota); + expect(actualBytes).toBeGreaterThan(expectedBytes - decreaseQuota); }); }); diff --git a/test/examples/add-3d-model-babylon.html b/test/examples/add-3d-model-babylon.html index c890da45a2..8f706cc286 100644 --- a/test/examples/add-3d-model-babylon.html +++ b/test/examples/add-3d-model-babylon.html @@ -140,8 +140,8 @@ this.map = map; }, - render (gl, matrix) { - const cameraMatrix = BABYLON.Matrix.FromArray(matrix); + render (gl, args) { + const cameraMatrix = BABYLON.Matrix.FromArray(args.defaultProjectionData.mainMatrix); // world-view-projection matrix const wvpMatrix = worldMatrix.multiply(cameraMatrix); diff --git a/test/examples/add-3d-model-with-terrain.html b/test/examples/add-3d-model-with-terrain.html index a656ca0729..d7da02d79f 100644 --- a/test/examples/add-3d-model-with-terrain.html +++ b/test/examples/add-3d-model-with-terrain.html @@ -170,7 +170,7 @@ this.renderer.autoClear = false; }, - render(gl, mercatorMatrix) { + render(gl, args) { // `queryTerrainElevation` gives us the elevation of a point on the terrain // **relative to the elevation of `center`**, @@ -187,7 +187,7 @@ scale: sceneOriginMercator.meterInMercatorCoordinateUnits() }; - const m = new THREE.Matrix4().fromArray(mercatorMatrix); + const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix); const l = new THREE.Matrix4() .makeTranslation(sceneTransform.translateX, sceneTransform.translateY, sceneTransform.translateZ) .scale(new THREE.Vector3(sceneTransform.scale, -sceneTransform.scale, sceneTransform.scale)); diff --git a/test/examples/add-3d-model.html b/test/examples/add-3d-model.html index 22b4064059..445c697a79 100644 --- a/test/examples/add-3d-model.html +++ b/test/examples/add-3d-model.html @@ -90,7 +90,7 @@ this.renderer.autoClear = false; }, - render (gl, matrix) { + render (gl, args) { const rotationX = new THREE.Matrix4().makeRotationAxis( new THREE.Vector3(1, 0, 0), modelTransform.rotateX @@ -104,7 +104,7 @@ modelTransform.rotateZ ); - const m = new THREE.Matrix4().fromArray(matrix); + const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix); const l = new THREE.Matrix4() .makeTranslation( modelTransform.translateX, @@ -122,6 +122,14 @@ .multiply(rotationY) .multiply(rotationZ); + // Alternatively, you can use this API to get the correct model matrix. + // It will work regardless of current projection. + // Also see the example "globe-3d-model.html". + // + // const modelMatrix = map.transform.getMatrixForModel(modelOrigin, modelAltitude); + // const m = new THREE.Matrix4().fromArray(matrix); + // const l = new THREE.Matrix4().fromArray(modelMatrix); + this.camera.projectionMatrix = m.multiply(l); this.renderer.resetState(); this.renderer.render(this.scene, this.camera); diff --git a/test/examples/custom-style-layer.html b/test/examples/custom-style-layer.html index a8891928b6..64122362d3 100644 --- a/test/examples/custom-style-layer.html +++ b/test/examples/custom-style-layer.html @@ -98,12 +98,12 @@ }, // method fired on each animation frame - render (gl, matrix) { + render (gl, args) { gl.useProgram(this.program); gl.uniformMatrix4fv( gl.getUniformLocation(this.program, 'u_matrix'), false, - matrix + args.defaultProjectionData.mainMatrix ); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); gl.enableVertexAttribArray(this.aPos); diff --git a/test/examples/globe-3d-model.html b/test/examples/globe-3d-model.html new file mode 100644 index 0000000000..1009212634 --- /dev/null +++ b/test/examples/globe-3d-model.html @@ -0,0 +1,147 @@ + + + + + Add a 3D model to globe with three.js + + + + + + + + + + + +
+
+ + + + + \ No newline at end of file diff --git a/test/examples/globe-atmosphere.html b/test/examples/globe-atmosphere.html new file mode 100644 index 0000000000..ee8ea25291 --- /dev/null +++ b/test/examples/globe-atmosphere.html @@ -0,0 +1,63 @@ + + + + Display a globe with an atmosphere + + + + + + + + + +
+ + + diff --git a/test/examples/globe-custom-simple.html b/test/examples/globe-custom-simple.html new file mode 100644 index 0000000000..5d8e8a1b61 --- /dev/null +++ b/test/examples/globe-custom-simple.html @@ -0,0 +1,229 @@ + + + + Add a simple custom layer on a globe + + + + + + + + +
+
+ + + + diff --git a/test/examples/globe-custom-tiles.html b/test/examples/globe-custom-tiles.html new file mode 100644 index 0000000000..84e1dc025c --- /dev/null +++ b/test/examples/globe-custom-tiles.html @@ -0,0 +1,310 @@ + + + + Add a custom layer with tiles to a globe + + + + + + + + +
+
+ + + + diff --git a/test/examples/globe-fill-extrusion.html b/test/examples/globe-fill-extrusion.html new file mode 100644 index 0000000000..bc51ab16bb --- /dev/null +++ b/test/examples/globe-fill-extrusion.html @@ -0,0 +1,106 @@ + + + + Display a globe with a fill extrusion layer + + + + + + + + +
+ + + diff --git a/test/examples/globe-vector-tiles.html b/test/examples/globe-vector-tiles.html new file mode 100644 index 0000000000..858cc77df9 --- /dev/null +++ b/test/examples/globe-vector-tiles.html @@ -0,0 +1,32 @@ + + + + Display a globe with a vector map + + + + + + + + +
+ + + diff --git a/test/examples/globe-zoom-planet-size-function.html b/test/examples/globe-zoom-planet-size-function.html new file mode 100644 index 0000000000..78b1343f97 --- /dev/null +++ b/test/examples/globe-zoom-planet-size-function.html @@ -0,0 +1,84 @@ + + + + Zoom and planet size relation on globe + + + + + + + + + +
+
+ + + + diff --git a/test/integration/assets/tiles/checkerboard.mvt b/test/integration/assets/tiles/checkerboard.mvt new file mode 100644 index 0000000000..60b373b181 Binary files /dev/null and b/test/integration/assets/tiles/checkerboard.mvt differ diff --git a/test/integration/assets/tiles/checkerboard.png b/test/integration/assets/tiles/checkerboard.png new file mode 100644 index 0000000000..1ee3721ff7 Binary files /dev/null and b/test/integration/assets/tiles/checkerboard.png differ diff --git a/test/integration/assets/tiles/ocean.mvt b/test/integration/assets/tiles/ocean.mvt new file mode 100644 index 0000000000..10eb450e94 Binary files /dev/null and b/test/integration/assets/tiles/ocean.mvt differ diff --git a/test/integration/assets/tiles/raster-zoom/0.png b/test/integration/assets/tiles/raster-zoom/0.png new file mode 100644 index 0000000000..aeeb634b29 Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/0.png differ diff --git a/test/integration/assets/tiles/raster-zoom/1.png b/test/integration/assets/tiles/raster-zoom/1.png new file mode 100644 index 0000000000..7a47318398 Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/1.png differ diff --git a/test/integration/assets/tiles/raster-zoom/2.png b/test/integration/assets/tiles/raster-zoom/2.png new file mode 100644 index 0000000000..46a63f1b26 Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/2.png differ diff --git a/test/integration/assets/tiles/raster-zoom/3.png b/test/integration/assets/tiles/raster-zoom/3.png new file mode 100644 index 0000000000..485ceda6d7 Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/3.png differ diff --git a/test/integration/assets/tiles/raster-zoom/4.png b/test/integration/assets/tiles/raster-zoom/4.png new file mode 100644 index 0000000000..7f99262fd1 Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/4.png differ diff --git a/test/integration/assets/tiles/raster-zoom/5.png b/test/integration/assets/tiles/raster-zoom/5.png new file mode 100644 index 0000000000..a23a9e04fb Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/5.png differ diff --git a/test/integration/assets/tiles/raster-zoom/6.png b/test/integration/assets/tiles/raster-zoom/6.png new file mode 100644 index 0000000000..b7d9d0c4fd Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/6.png differ diff --git a/test/integration/assets/tiles/raster-zoom/7.png b/test/integration/assets/tiles/raster-zoom/7.png new file mode 100644 index 0000000000..771c145cc4 Binary files /dev/null and b/test/integration/assets/tiles/raster-zoom/7.png differ diff --git a/test/integration/render/run_render_tests.ts b/test/integration/render/run_render_tests.ts index 6dd8f50964..3d3e2b680f 100644 --- a/test/integration/render/run_render_tests.ts +++ b/test/integration/render/run_render_tests.ts @@ -320,7 +320,7 @@ async function getImageFromStyle(styleForTest: StyleWithTestData, page: Page): P gl.linkProgram(this.program); } - render(gl: WebGL2RenderingContext, matrix) { + render(gl: WebGL2RenderingContext, args) { const vertexArray = new Float32Array([0.5, 0.5, 0.0]); gl.useProgram(this.program); const vertexBuffer = gl.createBuffer(); @@ -329,7 +329,7 @@ async function getImageFromStyle(styleForTest: StyleWithTestData, page: Page): P const posAttrib = gl.getAttribLocation(this.program, 'aPos'); gl.enableVertexAttribArray(posAttrib); gl.vertexAttribPointer(posAttrib, 3, gl.FLOAT, false, 0, 0); - gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_matrix'), false, matrix); + gl.uniformMatrix4fv(gl.getUniformLocation(this.program, 'u_matrix'), false, args.defaultProjectionData.mainMatrix); gl.drawArrays(gl.POINTS, 0, 1); } } @@ -412,13 +412,13 @@ async function getImageFromStyle(styleForTest: StyleWithTestData, page: Page): P gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indexArray, gl.STATIC_DRAW); } - render(gl: WebGL2RenderingContext, matrix) { + render(gl: WebGL2RenderingContext, args) { gl.useProgram(this.program); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); gl.enableVertexAttribArray(this.program.a_pos); gl.vertexAttribPointer(this.program.aPos, 3, gl.FLOAT, false, 0, 0); - gl.uniformMatrix4fv(this.program.uMatrix, false, matrix); + gl.uniformMatrix4fv(this.program.uMatrix, false, args.defaultProjectionData.mainMatrix); gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_SHORT, 0); } } diff --git a/test/integration/render/tests/debug/tile-raster/expected-win-flaky.png b/test/integration/render/tests/debug/tile-raster/expected-win-flaky.png new file mode 100644 index 0000000000..103e635e2e Binary files /dev/null and b/test/integration/render/tests/debug/tile-raster/expected-win-flaky.png differ diff --git a/test/integration/render/tests/icon-text-fit/text-variable-anchor-overlap/expected.png b/test/integration/render/tests/icon-text-fit/text-variable-anchor-overlap/expected.png index abc7219feb..866a7390ef 100644 Binary files a/test/integration/render/tests/icon-text-fit/text-variable-anchor-overlap/expected.png and b/test/integration/render/tests/icon-text-fit/text-variable-anchor-overlap/expected.png differ diff --git a/test/integration/render/tests/icon-text-fit/textFit-grid-long/expected-win-nvidia.png b/test/integration/render/tests/icon-text-fit/textFit-grid-long/expected-win-nvidia.png new file mode 100644 index 0000000000..b3eef01706 Binary files /dev/null and b/test/integration/render/tests/icon-text-fit/textFit-grid-long/expected-win-nvidia.png differ diff --git a/test/integration/render/tests/projection/globe/antimeridian-lod/expected.png b/test/integration/render/tests/projection/globe/antimeridian-lod/expected.png new file mode 100644 index 0000000000..162a7d075c Binary files /dev/null and b/test/integration/render/tests/projection/globe/antimeridian-lod/expected.png differ diff --git a/test/integration/render/tests/projection/globe/antimeridian-lod/style.json b/test/integration/render/tests/projection/globe/antimeridian-lod/style.json new file mode 100644 index 0000000000..c61d9ae6a1 --- /dev/null +++ b/test/integration/render/tests/projection/globe/antimeridian-lod/style.json @@ -0,0 +1,49 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that tile LOD is the same on both sides of the antimeridian.", + "width": 64, + "height": 64 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 179.99, + 0.0 + ], + "zoom": 6, + "projection": { + "type": "globe" + }, + "sources": { + "raster": { + "type": "raster", + "tiles": [ + "local://tiles/raster-zoom/{z}.png" + ], + "minzoom": 0, + "maxzoom": 7, + "tileSize": 512 + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "grey" + } + }, + { + "id": "raster", + "type": "raster", + "source": "raster", + "paint": { + "raster-fade-duration": 0 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/base/expected.png b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/base/expected.png new file mode 100644 index 0000000000..c3f6837235 Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/base/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/base/style.json b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/base/style.json new file mode 100644 index 0000000000..a442154b50 --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/base/style.json @@ -0,0 +1,49 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests atmosphere with atmosphere-blend to 0.5." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": 0.5 + }, + "light": { + "anchor": "map", + "position": [1.5, 90, 90] + }, + "center": [ + 160.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/atmosphere/atmosphere-blend/interpolate-to-0.5/expected.png b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0.5/expected.png new file mode 100644 index 0000000000..c39f55771b Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0.5/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0.5/style.json b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0.5/style.json new file mode 100644 index 0000000000..4ef3f67aac --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0.5/style.json @@ -0,0 +1,56 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests atmosphere with atmosphere-blend interpolated to a mid zoom level." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": [ + "interpolate", + ["linear"], + ["zoom"], + 0, 1, + 10, 1, + 12, 0 + ] + }, + "light": { + "anchor": "map", + "position": [1.5, 0, 180] + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 11, + "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/atmosphere/atmosphere-blend/interpolate-to-0/expected.png b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0/expected.png new file mode 100644 index 0000000000..2cfc4e76f1 Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0/style.json b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0/style.json new file mode 100644 index 0000000000..6a0151c0f3 --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-0/style.json @@ -0,0 +1,56 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests atmosphere with atmosphere-blend interpolated to a high zoom level." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": [ + "interpolate", + ["linear"], + ["zoom"], + 0, 1, + 10, 1, + 12, 0 + ] + }, + "light": { + "anchor": "map", + "position": [1.5, 0, 180] + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 12, + "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/atmosphere/atmosphere-blend/interpolate-to-1/expected.png b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-1/expected.png new file mode 100644 index 0000000000..fb63ed3de0 Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-1/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-1/style.json b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-1/style.json new file mode 100644 index 0000000000..ec84ba6d1d --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/atmosphere-blend/interpolate-to-1/style.json @@ -0,0 +1,56 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests atmosphere with atmosphere-blend interpolated to a mid zoom level." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": [ + "interpolate", + ["linear"], + ["zoom"], + 0, 1, + 10, 1, + 12, 0 + ] + }, + "light": { + "anchor": "map", + "position": [1.5, 0, 180] + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 10, + "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/atmosphere/base/expected.png b/test/integration/render/tests/projection/globe/atmosphere/base/expected.png new file mode 100644 index 0000000000..2eeec4b20d Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/base/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/base/style.json b/test/integration/render/tests/projection/globe/atmosphere/base/style.json new file mode 100644 index 0000000000..e99cddb72e --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/base/style.json @@ -0,0 +1,45 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that atmosphere is well display with blend to 1.0." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": 1.0 + }, + "center": [ + 0.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/atmosphere/light-position-map/expected.png b/test/integration/render/tests/projection/globe/atmosphere/light-position-map/expected.png new file mode 100644 index 0000000000..216be36e9e Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/light-position-map/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/light-position-map/style.json b/test/integration/render/tests/projection/globe/atmosphere/light-position-map/style.json new file mode 100644 index 0000000000..ed13867460 --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/light-position-map/style.json @@ -0,0 +1,49 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests atmosphere with position on right map." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": 1.0 + }, + "light": { + "anchor": "map", + "position": [1.5, 90, 90] + }, + "center": [ + 160.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/atmosphere/ligth-position-viewport/expected.png b/test/integration/render/tests/projection/globe/atmosphere/ligth-position-viewport/expected.png new file mode 100644 index 0000000000..b4ba6fb797 Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/ligth-position-viewport/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/ligth-position-viewport/style.json b/test/integration/render/tests/projection/globe/atmosphere/ligth-position-viewport/style.json new file mode 100644 index 0000000000..55b42c5c37 --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/ligth-position-viewport/style.json @@ -0,0 +1,49 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests atmosphere with position on left viewport." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": 1.0 + }, + "light": { + "anchor": "viewport", + "position": [1.5, 90, 90] + }, + "center": [ + 0.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/atmosphere/zoom/expected.png b/test/integration/render/tests/projection/globe/atmosphere/zoom/expected.png new file mode 100644 index 0000000000..0421635cd5 Binary files /dev/null and b/test/integration/render/tests/projection/globe/atmosphere/zoom/expected.png differ diff --git a/test/integration/render/tests/projection/globe/atmosphere/zoom/style.json b/test/integration/render/tests/projection/globe/atmosphere/zoom/style.json new file mode 100644 index 0000000000..af758e2bd3 --- /dev/null +++ b/test/integration/render/tests/projection/globe/atmosphere/zoom/style.json @@ -0,0 +1,49 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests atmosphere with high zoom level." + } + }, + "projection": { "type": "globe" }, + "sky": { + "atmosphere-blend": 1.0 + }, + "light": { + "anchor": "map", + "position": [1.5, 90, 90] + }, + "center": [ + 160.0, + 0.0 + ], + "zoom": 8, + "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/background-pattern/expected.png b/test/integration/render/tests/projection/globe/background-pattern/expected.png new file mode 100644 index 0000000000..ab35f0162d Binary files /dev/null and b/test/integration/render/tests/projection/globe/background-pattern/expected.png differ diff --git a/test/integration/render/tests/projection/globe/background-pattern/expected_ci_ubuntu.png b/test/integration/render/tests/projection/globe/background-pattern/expected_ci_ubuntu.png new file mode 100644 index 0000000000..958522f2eb Binary files /dev/null and b/test/integration/render/tests/projection/globe/background-pattern/expected_ci_ubuntu.png differ diff --git a/test/integration/render/tests/projection/globe/background-pattern/expected_ci_windows.png b/test/integration/render/tests/projection/globe/background-pattern/expected_ci_windows.png new file mode 100644 index 0000000000..961e8912f5 Binary files /dev/null and b/test/integration/render/tests/projection/globe/background-pattern/expected_ci_windows.png differ diff --git a/test/integration/render/tests/projection/globe/background-pattern/style.json b/test/integration/render/tests/projection/globe/background-pattern/style.json new file mode 100644 index 0000000000..6ac6c58a37 --- /dev/null +++ b/test/integration/render/tests/projection/globe/background-pattern/style.json @@ -0,0 +1,23 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 2, + "sources": {}, + "sprite": "local://sprites/emerald", + "projection": { "type": "globe" }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-pattern": "cemetery_icon" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/background/expected.png b/test/integration/render/tests/projection/globe/background/expected.png new file mode 100644 index 0000000000..f2f875331a Binary files /dev/null and b/test/integration/render/tests/projection/globe/background/expected.png differ diff --git a/test/integration/render/tests/projection/globe/background/style.json b/test/integration/render/tests/projection/globe/background/style.json new file mode 100644 index 0000000000..013c42dc10 --- /dev/null +++ b/test/integration/render/tests/projection/globe/background/style.json @@ -0,0 +1,26 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 1, + "sources": {}, + "projection": { "type": "globe" }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "green" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected-flaky.png new file mode 100644 index 0000000000..4983290a7d Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected-win-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected-win-flaky.png new file mode 100644 index 0000000000..4983290a7d Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected-win-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected.png new file mode 100644 index 0000000000..1d27eb8f66 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected_debian.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected_debian.png new file mode 100644 index 0000000000..a93dbf5882 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/expected_debian.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/style.json b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/style.json new file mode 100644 index 0000000000..55e7967c7f --- /dev/null +++ b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-map/style.json @@ -0,0 +1,63 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 256, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0, + 0 + ], + "projection": { "type": "globe" }, + "zoom": 3, + "pitch": 60, + "bearing": 90, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "MultiPoint", + "coordinates": [ + [ + -10, + 0 + ], + [ + 0, + 0 + ], + [ + 10, + 0 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "circles", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 40, + "circle-color": "blue", + "circle-pitch-alignment": "map", + "circle-pitch-scale": "map", + "circle-opacity": 0.5 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected-flaky.png new file mode 100644 index 0000000000..e2f5f392db Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected-win-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected-win-flaky.png new file mode 100644 index 0000000000..e2f5f392db Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected-win-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected.png new file mode 100644 index 0000000000..32c72955a9 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected_debian.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected_debian.png new file mode 100644 index 0000000000..71f7b40719 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/expected_debian.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/style.json b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/style.json new file mode 100644 index 0000000000..4bf1c7de8e --- /dev/null +++ b/test/integration/render/tests/projection/globe/circle-pitch-alignment/map-scale-viewport/style.json @@ -0,0 +1,74 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 256, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0, + 0 + ], + "zoom": 3, + "pitch": 60, + "bearing": 90, + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "MultiPoint", + "coordinates": [ + [ + -10, + 0 + ], + [ + 0, + 0 + ], + [ + 10, + 0 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "circles-wrong", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 40, + "circle-color": "red", + "circle-pitch-alignment": "map", + "circle-pitch-scale": "map" + } + }, + { + "id": "circles", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 40, + "circle-color": "blue", + "circle-pitch-alignment": "map", + "circle-pitch-scale": "viewport", + "circle-opacity": 0.5 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected-flaky.png new file mode 100644 index 0000000000..f6f1882224 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected-win-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected-win-flaky.png new file mode 100644 index 0000000000..f6f1882224 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected-win-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected.png new file mode 100644 index 0000000000..138ae0b965 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected_debian.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected_debian.png new file mode 100644 index 0000000000..bded4a550d Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/expected_debian.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/style.json b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/style.json new file mode 100644 index 0000000000..af50adcc01 --- /dev/null +++ b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-map/style.json @@ -0,0 +1,63 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 256, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0, + 0 + ], + "zoom": 3, + "pitch": 60, + "bearing": 90, + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "MultiPoint", + "coordinates": [ + [ + -10, + 0 + ], + [ + 0, + 0 + ], + [ + 10, + 0 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "circles", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 40, + "circle-color": "blue", + "circle-pitch-alignment": "viewport", + "circle-pitch-scale": "map", + "circle-opacity": 0.5 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected-flaky.png new file mode 100644 index 0000000000..1404fb7b67 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected-win-flaky.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected-win-flaky.png new file mode 100644 index 0000000000..1404fb7b67 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected-win-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected.png new file mode 100644 index 0000000000..3f167c1c03 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected_debian.png b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected_debian.png new file mode 100644 index 0000000000..ea23fdae05 Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/expected_debian.png differ diff --git a/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/style.json b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/style.json new file mode 100644 index 0000000000..e2d8915458 --- /dev/null +++ b/test/integration/render/tests/projection/globe/circle-pitch-alignment/viewport-scale-viewport/style.json @@ -0,0 +1,63 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 256, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0, + 0 + ], + "zoom": 3, + "pitch": 60, + "bearing": 90, + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "MultiPoint", + "coordinates": [ + [ + -10, + 0 + ], + [ + 0, + 0 + ], + [ + 10, + 0 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "circles", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 40, + "circle-color": "blue", + "circle-pitch-alignment": "viewport", + "circle-pitch-scale": "viewport", + "circle-opacity": 0.5 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/circle-planet/expected.png b/test/integration/render/tests/projection/globe/circle-planet/expected.png new file mode 100644 index 0000000000..ef338655cc Binary files /dev/null and b/test/integration/render/tests/projection/globe/circle-planet/expected.png differ diff --git a/test/integration/render/tests/projection/globe/circle-planet/style.json b/test/integration/render/tests/projection/globe/circle-planet/style.json new file mode 100644 index 0000000000..27d893778c --- /dev/null +++ b/test/integration/render/tests/projection/globe/circle-planet/style.json @@ -0,0 +1,119 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 256, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0, + 0 + ], + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "MultiPoint", + "coordinates": [ + [ + -60, + 0 + ], + [ + -45, + 0 + ], + [ + -30, + 0 + ], + [ + -15, + 0 + ], + [ + 0, + 0 + ], + [ + 15, + 0 + ], + [ + 30, + 0 + ], + [ + 45, + 0 + ], + [ + 60, + 0 + ], + [ + 0, + -60 + ], + [ + 0, + -45 + ], + [ + 0, + -30 + ], + [ + 0, + -15 + ], + [ + 0, + 15 + ], + [ + 0, + 30 + ], + [ + 0, + 45 + ], + [ + 0, + 60 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "gray" + } + }, + { + "id": "circles", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 20, + "circle-color": "rgba(0, 0, 0, 0.5)", + "circle-stroke-color": "white", + "circle-stroke-width": 4, + "circle-pitch-alignment": "map", + "circle-pitch-scale": "map", + "circle-opacity": 0.5 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-icon-text-translate-map/expected.png b/test/integration/render/tests/projection/globe/collision-icon-text-translate-map/expected.png new file mode 100644 index 0000000000..09ae8e2857 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-icon-text-translate-map/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-icon-text-translate-map/style.json b/test/integration/render/tests/projection/globe/collision-icon-text-translate-map/style.json new file mode 100644 index 0000000000..316db9cfc7 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-icon-text-translate-map/style.json @@ -0,0 +1,90 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "bearing": 45, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/sprite", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [ + -50, + 0 + ], + [ + 0, + 0 + ], + [ + 50, + 0 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle_base", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "icon-image": "fav-airport-18", + "text-field": "abc", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "symbol-placement": "point", + "symbol-spacing": 20 + }, + "paint": { + "icon-opacity": 1, + "text-color": "hsl(0, 82%, 48%)", + "text-translate-anchor": "map", + "text-translate": [ + 0, + -50 + ], + "icon-translate-anchor": "map", + "icon-translate": [ + -50, + -50 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-text-line-pole-to-pole/expected.png b/test/integration/render/tests/projection/globe/collision-text-line-pole-to-pole/expected.png new file mode 100644 index 0000000000..af1bc7d712 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-line-pole-to-pole/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-line-pole-to-pole/style.json b/test/integration/render/tests/projection/globe/collision-text-line-pole-to-pole/style.json new file mode 100644 index 0000000000..73b29cea23 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-text-line-pole-to-pole/style.json @@ -0,0 +1,74 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 0, + -85 + ], + [ + 0, + 85 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line", + "type": "line", + "source": "geojson", + "layout": { + "line-join": "round", + "line-cap": "round" + }, + "paint": { + "line-color": "green", + "line-width": 1 + } + }, + { + "id": "text", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line", + "text-field": "AAAA AAAAAA A AAAAA AAAA BB", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/expected-debian.png b/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/expected-debian.png new file mode 100644 index 0000000000..71dede5911 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/expected-debian.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/expected.png b/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/expected.png new file mode 100644 index 0000000000..964a82c1a5 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/style.json b/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/style.json new file mode 100644 index 0000000000..e92eeb85f2 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-text-pitched-rotated/style.json @@ -0,0 +1,141 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "pitch": 30, + "bearing": 45, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [ + 0, + -80 + ], + [ + 0, + -60 + ], + [ + 0, + -40 + ], + [ + 0, + -20 + ], + [ + 0, + 0 + ], + [ + 0, + 20 + ], + [ + 0, + 40 + ], + [ + 0, + 60 + ], + [ + 0, + 80 + ], + [ + 80, + 0 + ], + [ + 60, + 0 + ], + [ + 40, + 0 + ], + [ + 20, + 0 + ], + [ + -20, + 0 + ], + [ + -40, + 0 + ], + [ + -60, + 0 + ], + [ + -80, + 0 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 3, + "circle-color": "blue" + } + }, + { + "id": "text", + "type": "symbol", + "source": "geojson", + "layout": { + "text-field": "TEST", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "left" + }, + "paint": { + "text-translate": [ + 10, + 0 + ], + "text-translate-anchor": "map" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-text-point-pole-to-pole/expected.png b/test/integration/render/tests/projection/globe/collision-text-point-pole-to-pole/expected.png new file mode 100644 index 0000000000..5e701b3725 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-point-pole-to-pole/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-point-pole-to-pole/style.json b/test/integration/render/tests/projection/globe/collision-text-point-pole-to-pole/style.json new file mode 100644 index 0000000000..e395a94c97 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-text-point-pole-to-pole/style.json @@ -0,0 +1,109 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [ + 0, + -80 + ], + [ + 0, + -60 + ], + [ + 0, + -40 + ], + [ + 0, + -20 + ], + [ + 0, + 0 + ], + [ + 0, + 20 + ], + [ + 0, + 40 + ], + [ + 0, + 60 + ], + [ + 0, + 80 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 3, + "circle-color": "blue" + } + }, + { + "id": "text", + "type": "symbol", + "source": "geojson", + "layout": { + "text-field": "TEST", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "left", + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate": [ + 10, + 0 + ], + "text-translate-anchor": "map" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/base/expected.png b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/base/expected.png new file mode 100644 index 0000000000..7199317b99 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/base/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/base/style.json b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/base/style.json new file mode 100644 index 0000000000..fe3b2a2291 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/base/style.json @@ -0,0 +1,564 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 2, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/expected-2.png b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/expected-2.png new file mode 100644 index 0000000000..82276ae5c5 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/expected-2.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/expected.png b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/expected.png new file mode 100644 index 0000000000..61a7d4885f Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/style.json b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/style.json new file mode 100644 index 0000000000..fc9540c423 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched-and-rotated/style.json @@ -0,0 +1,570 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 15, + 0 + ], + "zoom": 2, + "pitch": 40, + "bearing": 60, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched/expected.png b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched/expected.png new file mode 100644 index 0000000000..11c459117f Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched/style.json b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched/style.json new file mode 100644 index 0000000000..de40b8a4a5 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/pitched/style.json @@ -0,0 +1,565 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 2, + "pitch": 60, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/rotated/expected.png b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/rotated/expected.png new file mode 100644 index 0000000000..d045b352e9 Binary files /dev/null and b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/rotated/expected.png differ diff --git a/test/integration/render/tests/projection/globe/collision-text-variable-anchor/rotated/style.json b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/rotated/style.json new file mode 100644 index 0000000000..8c8244c3f3 --- /dev/null +++ b/test/integration/render/tests/projection/globe/collision-text-variable-anchor/rotated/style.json @@ -0,0 +1,565 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 2, + "bearing": 60, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-extrusion-translate/expected.png b/test/integration/render/tests/projection/globe/fill-extrusion-translate/expected.png new file mode 100644 index 0000000000..0c260c5f35 Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-extrusion-translate/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-extrusion-translate/style.json b/test/integration/render/tests/projection/globe/fill-extrusion-translate/style.json new file mode 100644 index 0000000000..2265be140e --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-extrusion-translate/style.json @@ -0,0 +1,138 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 10.0, + -15.0 + ], + "pitch": 45, + "bearing": 45, + "zoom": 1.5, + "projection": { "type": "globe" }, + "sources": { + "fill": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -90 + ], + [ + -180, + 90 + ], + [ + 180, + 90 + ], + [ + 180, + -90 + ], + [ + -180, + -90 + ] + ] + ] + } + }, + "test": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -10, + -10 + ], + [ + -10, + 10 + ], + [ + 10, + 10 + ], + [ + 10, + -10 + ], + [ + -10, + -10 + ] + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "fill", + "paint": { + "fill-antialias": false, + "fill-color": "grey" + } + }, + { + "id": "fill-test-base", + "type": "fill-extrusion", + "source": "test", + "paint": { + "fill-extrusion-color": "#ff0000", + "fill-extrusion-opacity": 1, + "fill-extrusion-height": 450000 + } + }, + { + "id": "fill-test-translate-map", + "type": "fill-extrusion", + "source": "test", + "paint": { + "fill-extrusion-color": "#00ff00", + "fill-extrusion-translate": [ + 10, + 50 + ], + "fill-extrusion-translate-anchor": "map", + "fill-extrusion-opacity": 1, + "fill-extrusion-height": 600000 + } + }, + { + "id": "fill-test-translate-viewport", + "type": "fill-extrusion", + "source": "test", + "paint": { + "fill-extrusion-color": "#0000ff", + "fill-extrusion-translate": [ + 10, + 50 + ], + "fill-extrusion-translate-anchor": "viewport", + "fill-extrusion-opacity": 1, + "fill-extrusion-height": 750000 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-extrusion/expected.png b/test/integration/render/tests/projection/globe/fill-extrusion/expected.png new file mode 100644 index 0000000000..3ce509db07 Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-extrusion/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-extrusion/style.json b/test/integration/render/tests/projection/globe/fill-extrusion/style.json new file mode 100644 index 0000000000..43d390b4e4 --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-extrusion/style.json @@ -0,0 +1,146 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + -30.0 + ], + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -90 + ], + [ + -180, + 90 + ], + [ + 180, + 90 + ], + [ + 180, + -90 + ], + [ + -180, + -90 + ] + ] + ] + } + }, + "extrusion": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -10 + ], + [ + -180, + 10 + ], + [ + 180, + 10 + ], + [ + 180, + -10 + ], + [ + -180, + -10 + ] + ] + ] + } + }, + "extrusion2": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -10, + 40 + ], + [ + -10, + 50 + ], + [ + 10, + 50 + ], + [ + 10, + 40 + ], + [ + -10, + 40 + ] + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "geojson", + "paint": { + "fill-antialias": false, + "fill-color": "#0000ff" + } + }, + { + "id": "extrusion", + "type": "fill-extrusion", + "source": "extrusion", + "paint": { + "fill-extrusion-color": "#ff0000", + "fill-extrusion-opacity": 1, + "fill-extrusion-height": 450000 + } + }, + { + "id": "extrusion2", + "type": "fill-extrusion", + "source": "extrusion2", + "paint": { + "fill-extrusion-color": "#00ff00", + "fill-extrusion-opacity": 1, + "fill-extrusion-height": 450000 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-planet-pitched/expected.png b/test/integration/render/tests/projection/globe/fill-planet-pitched/expected.png new file mode 100644 index 0000000000..acb0a2c5ae Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-planet-pitched/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-planet-pitched/style.json b/test/integration/render/tests/projection/globe/fill-planet-pitched/style.json new file mode 100644 index 0000000000..1cce46f7b9 --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-planet-pitched/style.json @@ -0,0 +1,44 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + 0.0 + ], + "pitch": 30, + "zoom": 2, + "projection": { "type": "globe" }, + "sources": { + "vector_tiles": { + "type": "vector", + "maxzoom": 0, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "land", + "type": "fill", + "source": "vector_tiles", + "source-layer": "water", + "paint": { + "fill-color": "blue" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-planet-pole/expected.png b/test/integration/render/tests/projection/globe/fill-planet-pole/expected.png new file mode 100644 index 0000000000..9ecd7ae81b Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-planet-pole/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-planet-pole/style.json b/test/integration/render/tests/projection/globe/fill-planet-pole/style.json new file mode 100644 index 0000000000..acab88b17c --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-planet-pole/style.json @@ -0,0 +1,44 @@ +{ + "version": 8, + "metadata": { + "test": {} + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + 80.0 + ], + "zoom": -0.5, + "projection": { + "type": "globe" + }, + "sources": { + "vector_tiles": { + "type": "vector", + "maxzoom": 0, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "land", + "type": "fill", + "source": "vector_tiles", + "source-layer": "water", + "paint": { + "fill-color": "blue" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-planet-solid/expected.png b/test/integration/render/tests/projection/globe/fill-planet-solid/expected.png new file mode 100644 index 0000000000..b193ded923 Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-planet-solid/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-planet-solid/style.json b/test/integration/render/tests/projection/globe/fill-planet-solid/style.json new file mode 100644 index 0000000000..c93b37269b --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-planet-solid/style.json @@ -0,0 +1,66 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -90 + ], + [ + -180, + 90 + ], + [ + 180, + 90 + ], + [ + 180, + -90 + ], + [ + -180, + -90 + ] + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "geojson", + "paint": { + "fill-antialias": false, + "fill-color": "#0000ff" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-planet-tiles/expected.png b/test/integration/render/tests/projection/globe/fill-planet-tiles/expected.png new file mode 100644 index 0000000000..c386f35c6f Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-planet-tiles/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-planet-tiles/style.json b/test/integration/render/tests/projection/globe/fill-planet-tiles/style.json new file mode 100644 index 0000000000..b1f61efbbc --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-planet-tiles/style.json @@ -0,0 +1,43 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 2, + "projection": { "type": "globe" }, + "sources": { + "vector_tiles": { + "type": "vector", + "maxzoom": 0, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "land", + "type": "fill", + "source": "vector_tiles", + "source-layer": "water", + "paint": { + "fill-color": "blue" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected-win-flaky.png b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected-win-flaky.png new file mode 100644 index 0000000000..cd2ba642f3 Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected-win-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected.png b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected.png new file mode 100644 index 0000000000..02c6c1519c Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected_debian.png b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected_debian.png new file mode 100644 index 0000000000..bd1a255d99 Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/expected_debian.png differ diff --git a/test/integration/render/tests/projection/globe/fill-seams/checkerboard/style.json b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/style.json new file mode 100644 index 0000000000..5496143a02 --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-seams/checkerboard/style.json @@ -0,0 +1,47 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that tiny seams caused by improper stencil mask border handling are not visible.", + "width": 256, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + -151.855, + 61.590 + ], + "zoom": 7.95, + "projection": { + "type": "globe" + }, + "sources": { + "vector_tiles": { + "type": "vector", + "tiles": [ + "local://tiles/checkerboard.mvt" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "ocean", + "type": "fill", + "source": "vector_tiles", + "source-layer": "water", + "paint": { + "fill-color": "black" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-seams/ocean/expected.png b/test/integration/render/tests/projection/globe/fill-seams/ocean/expected.png new file mode 100644 index 0000000000..ee9dc4521c Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-seams/ocean/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-seams/ocean/style.json b/test/integration/render/tests/projection/globe/fill-seams/ocean/style.json new file mode 100644 index 0000000000..783e7047ac --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-seams/ocean/style.json @@ -0,0 +1,49 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that single-pixel seams are not visible. These seams were caused by a mismatch between stencil mask and fill layer subdivision granularity.", + "width": 64, + "height": 1024 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + -150.52, + 61.00 + ], + "pitch": 60.0, + "bearing": 180.0, + "zoom": 7, + "projection": { + "type": "globe" + }, + "sources": { + "vector_tiles": { + "type": "vector", + "tiles": [ + "local://tiles/ocean.mvt" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "ocean", + "type": "fill", + "source": "vector_tiles", + "source-layer": "water", + "paint": { + "fill-color": "blue" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/fill-translate/expected.png b/test/integration/render/tests/projection/globe/fill-translate/expected.png new file mode 100644 index 0000000000..f8795567d7 Binary files /dev/null and b/test/integration/render/tests/projection/globe/fill-translate/expected.png differ diff --git a/test/integration/render/tests/projection/globe/fill-translate/style.json b/test/integration/render/tests/projection/globe/fill-translate/style.json new file mode 100644 index 0000000000..0d8dff9e09 --- /dev/null +++ b/test/integration/render/tests/projection/globe/fill-translate/style.json @@ -0,0 +1,132 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 10.0, + -15.0 + ], + "pitch": 15, + "bearing": 45, + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "fill": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -90 + ], + [ + -180, + 90 + ], + [ + 180, + 90 + ], + [ + 180, + -90 + ], + [ + -180, + -90 + ] + ] + ] + } + }, + "test": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -10, + -10 + ], + [ + -10, + 10 + ], + [ + 10, + 10 + ], + [ + 10, + -10 + ], + [ + -10, + -10 + ] + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "fill", + "paint": { + "fill-antialias": false, + "fill-color": "grey" + } + }, + { + "id": "fill-test-base", + "type": "fill", + "source": "test", + "paint": { + "fill-color": "#ff0000" + } + }, + { + "id": "fill-test-translate-map", + "type": "fill", + "source": "test", + "paint": { + "fill-color": "#00ff00", + "fill-translate": [ + 10, + 50 + ], + "fill-translate-anchor": "map" + } + }, + { + "id": "fill-test-translate-viewport", + "type": "fill", + "source": "test", + "paint": { + "fill-color": "#0000ff", + "fill-translate": [ + 10, + 50 + ], + "fill-translate-anchor": "viewport" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/heatmap/expected.png b/test/integration/render/tests/projection/globe/heatmap/expected.png new file mode 100644 index 0000000000..6bf7f301ff Binary files /dev/null and b/test/integration/render/tests/projection/globe/heatmap/expected.png differ diff --git a/test/integration/render/tests/projection/globe/heatmap/style.json b/test/integration/render/tests/projection/globe/heatmap/style.json new file mode 100644 index 0000000000..3007ca891e --- /dev/null +++ b/test/integration/render/tests/projection/globe/heatmap/style.json @@ -0,0 +1,97 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 256, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0, + 0 + ], + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "MultiPoint", + "coordinates": [ + [ + -90, + 0 + ], + [ + -60, + 0 + ], + [ + -30, + 0 + ], + [ + 0, + 0 + ], + [ + 30, + 0 + ], + [ + 60, + 0 + ], + [ + 90, + 0 + ], + [ + 0, + -90 + ], + [ + 0, + -60 + ], + [ + 0, + -30 + ], + [ + 0, + 30 + ], + [ + 0, + 60 + ], + [ + 0, + 90 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "grey" + } + }, + { + "id": "heatmap", + "type": "heatmap", + "source": "geojson", + "paint": { + "heatmap-radius": 60 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/hillshade/expected.png b/test/integration/render/tests/projection/globe/hillshade/expected.png new file mode 100644 index 0000000000..a3abf494ea Binary files /dev/null and b/test/integration/render/tests/projection/globe/hillshade/expected.png differ diff --git a/test/integration/render/tests/projection/globe/hillshade/style.json b/test/integration/render/tests/projection/globe/hillshade/style.json new file mode 100644 index 0000000000..b9dcde29d0 --- /dev/null +++ b/test/integration/render/tests/projection/globe/hillshade/style.json @@ -0,0 +1,44 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256, + "allowed": 0.05 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + -118.12, + 36.60 + ], + "zoom": 4.5, + "projection": { "type": "globe" }, + "sources": { + "hillshadeSource": { + "type": "raster-dem", + "tiles": [ + "local://tiles/terrain/{z}-{x}-{y}.terrain.png" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "hills", + "type": "hillshade", + "source": "hillshadeSource", + "layout": { + "visibility": "visible" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/icon-text-translate-map/expected.png b/test/integration/render/tests/projection/globe/icon-text-translate-map/expected.png new file mode 100644 index 0000000000..e4d48f008f Binary files /dev/null and b/test/integration/render/tests/projection/globe/icon-text-translate-map/expected.png differ diff --git a/test/integration/render/tests/projection/globe/icon-text-translate-map/style.json b/test/integration/render/tests/projection/globe/icon-text-translate-map/style.json new file mode 100644 index 0000000000..25bcd09b53 --- /dev/null +++ b/test/integration/render/tests/projection/globe/icon-text-translate-map/style.json @@ -0,0 +1,89 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "bearing": 45, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/sprite", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [ + -50, + 0 + ], + [ + 0, + 0 + ], + [ + 50, + 0 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle_base", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "icon-image": "fav-airport-18", + "text-field": "abc", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "symbol-placement": "point", + "symbol-spacing": 20 + }, + "paint": { + "icon-opacity": 1, + "text-color": "hsl(0, 82%, 48%)", + "text-translate-anchor": "map", + "text-translate": [ + 0, + -50 + ], + "icon-translate-anchor": "map", + "icon-translate": [ + -50, + -50 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/icon-text-translate-viewport/expected.png b/test/integration/render/tests/projection/globe/icon-text-translate-viewport/expected.png new file mode 100644 index 0000000000..48aee8f740 Binary files /dev/null and b/test/integration/render/tests/projection/globe/icon-text-translate-viewport/expected.png differ diff --git a/test/integration/render/tests/projection/globe/icon-text-translate-viewport/style.json b/test/integration/render/tests/projection/globe/icon-text-translate-viewport/style.json new file mode 100644 index 0000000000..88ce96ba56 --- /dev/null +++ b/test/integration/render/tests/projection/globe/icon-text-translate-viewport/style.json @@ -0,0 +1,89 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "bearing": 45, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/sprite", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [ + -50, + 0 + ], + [ + 0, + 0 + ], + [ + 50, + 0 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle_base", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "icon-image": "fav-airport-18", + "text-field": "abc", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "symbol-placement": "point", + "symbol-spacing": 20 + }, + "paint": { + "icon-opacity": 1, + "text-color": "hsl(0, 82%, 48%)", + "text-translate-anchor": "viewport", + "text-translate": [ + 0, + -50 + ], + "icon-translate-anchor": "viewport", + "icon-translate": [ + -50, + -50 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/image/expected.png b/test/integration/render/tests/projection/globe/image/expected.png new file mode 100644 index 0000000000..484e718798 Binary files /dev/null and b/test/integration/render/tests/projection/globe/image/expected.png differ diff --git a/test/integration/render/tests/projection/globe/image/style.json b/test/integration/render/tests/projection/globe/image/style.json new file mode 100644 index 0000000000..12cf9b42ed --- /dev/null +++ b/test/integration/render/tests/projection/globe/image/style.json @@ -0,0 +1,60 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "This test ensures that images that are placed into the root tile do not stretch all the way to the poles.", + "width": 64, + "height": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 0.5, + "center": [ + 0, + 45 + ], + "projection": { "type": "globe" }, + "sources": { + "image": { + "type": "image", + "coordinates": [ + [ + -270, + -80 + ], + [ + 90, + -80 + ], + [ + 90, + 80 + ], + [ + -270, + 80 + ] + ], + "url": "local://image/0.png" + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "green" + } + }, + { + "id": "image", + "type": "raster", + "source": "image", + "paint": { + "raster-fade-duration": 0 + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/line-gradient/expected.png b/test/integration/render/tests/projection/globe/line-gradient/expected.png new file mode 100644 index 0000000000..84b8c1caee Binary files /dev/null and b/test/integration/render/tests/projection/globe/line-gradient/expected.png differ diff --git a/test/integration/render/tests/projection/globe/line-gradient/style.json b/test/integration/render/tests/projection/globe/line-gradient/style.json new file mode 100644 index 0000000000..37b895abb1 --- /dev/null +++ b/test/integration/render/tests/projection/globe/line-gradient/style.json @@ -0,0 +1,112 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "fill": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -90 + ], + [ + -180, + 90 + ], + [ + 180, + 90 + ], + [ + 180, + -90 + ], + [ + -180, + -90 + ] + ] + ] + } + }, + "line": { + "type": "geojson", + "lineMetrics": true, + "data": { + "type": "LineString", + "coordinates": [ + [ + -90, + -85 + ], + [ + 90, + 85 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "fill", + "paint": { + "fill-antialias": false, + "fill-color": "grey" + } + }, + { + "id": "line", + "type": "line", + "source": "line", + "paint": { + "line-width": 10, + "line-gradient": [ + "interpolate-lab", + [ + "linear" + ], + [ + "line-progress" + ], + 0, + "blue", + 0.1, + "royalblue", + 0.3, + "cyan", + 0.5, + "lime", + 0.7, + "yellow", + 1, + "red" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/line-spiral/expected.png b/test/integration/render/tests/projection/globe/line-spiral/expected.png new file mode 100644 index 0000000000..7a1ea84466 Binary files /dev/null and b/test/integration/render/tests/projection/globe/line-spiral/expected.png differ diff --git a/test/integration/render/tests/projection/globe/line-spiral/style.json b/test/integration/render/tests/projection/globe/line-spiral/style.json new file mode 100644 index 0000000000..94e4d2f7d8 --- /dev/null +++ b/test/integration/render/tests/projection/globe/line-spiral/style.json @@ -0,0 +1,91 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 0.0, + 0.0 + ], + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "fill": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -90 + ], + [ + -180, + 90 + ], + [ + 180, + 90 + ], + [ + 180, + -90 + ], + [ + -180, + -90 + ] + ] + ] + } + }, + "line": { + "type": "geojson", + "data": { + "type": "LineString", + "coordinates": [ + [ + -1440, + -85 + ], + [ + 1440, + 85 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "fill", + "paint": { + "fill-antialias": false, + "fill-color": "grey" + } + }, + { + "id": "line", + "type": "line", + "source": "line", + "paint": { + "line-width": 10, + "line-color": "red" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/line-translate/expected.png b/test/integration/render/tests/projection/globe/line-translate/expected.png new file mode 100644 index 0000000000..d5c3e9ae98 Binary files /dev/null and b/test/integration/render/tests/projection/globe/line-translate/expected.png differ diff --git a/test/integration/render/tests/projection/globe/line-translate/style.json b/test/integration/render/tests/projection/globe/line-translate/style.json new file mode 100644 index 0000000000..50bce05b76 --- /dev/null +++ b/test/integration/render/tests/projection/globe/line-translate/style.json @@ -0,0 +1,122 @@ +{ + "version": 8, + "metadata": { + "test": { + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 10.0, + -15.0 + ], + "pitch": 15, + "bearing": 45, + "zoom": 1, + "projection": { "type": "globe" }, + "sources": { + "fill": { + "type": "geojson", + "data": { + "type": "Polygon", + "coordinates": [ + [ + [ + -180, + -90 + ], + [ + -180, + 90 + ], + [ + 180, + 90 + ], + [ + 180, + -90 + ], + [ + -180, + -90 + ] + ] + ] + } + }, + "line": { + "type": "geojson", + "lineMetrics": true, + "data": { + "type": "LineString", + "coordinates": [ + [ + -30, + 0 + ], + [ + 30, + 0 + ] + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "fill", + "type": "fill", + "source": "fill", + "paint": { + "fill-antialias": false, + "fill-color": "grey" + } + }, + { + "id": "line", + "type": "line", + "source": "line", + "paint": { + "line-width": 10, + "line-color": "#ff0000" + } + }, + { + "id": "line-translate-map", + "type": "line", + "source": "line", + "paint": { + "line-width": 10, + "line-color": "#00ff00", + "line-translate": [ + 10, + 50 + ], + "line-translate-anchor": "map" + } + }, + { + "id": "line-translate-viewport", + "type": "line", + "source": "line", + "paint": { + "line-width": 10, + "line-color": "#0000ff", + "line-translate": [ + 10, + 50 + ], + "line-translate-anchor": "viewport" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/raster-planet/expected-flaky.png b/test/integration/render/tests/projection/globe/raster-planet/expected-flaky.png new file mode 100644 index 0000000000..4f7b1c27a4 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-planet/expected-flaky.png differ 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..cf55208032 --- /dev/null +++ b/test/integration/render/tests/projection/globe/raster-planet/style.json @@ -0,0 +1,45 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that globe projection works with the raster layer type." + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 15.0, + 0.0 + ], + "zoom": 1, + "projection": { "type": "globe" }, + "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-win-flaky.png b/test/integration/render/tests/projection/globe/raster-pole/expected-win-flaky.png new file mode 100644 index 0000000000..b7eaa69d5f Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-pole/expected-win-flaky.png differ 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..0fbf297cd9 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..75e79b21aa --- /dev/null +++ b/test/integration/render/tests/projection/globe/raster-pole/style.json @@ -0,0 +1,47 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that globe projection of raster layer fills the poles properly." + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 15.0, + 80.0 + ], + "zoom": -0.5, + "projection": { + "type": "globe" + }, + "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-warped/expected-debian.png b/test/integration/render/tests/projection/globe/raster-warped/expected-debian.png new file mode 100644 index 0000000000..7215416c0a Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-debian.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky.png b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky.png new file mode 100644 index 0000000000..e607782220 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky2.png b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky2.png new file mode 100644 index 0000000000..8751c88180 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky2.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky3.png b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky3.png new file mode 100644 index 0000000000..2bb5d66df5 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky3.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky4.png b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky4.png new file mode 100644 index 0000000000..8290078063 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky4.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky5.png b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky5.png new file mode 100644 index 0000000000..1602e29988 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky5.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky6.png b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky6.png new file mode 100644 index 0000000000..579d11177a Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky6.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky7.png b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky7.png new file mode 100644 index 0000000000..6f396f5d30 Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected-win-flaky7.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/expected.png b/test/integration/render/tests/projection/globe/raster-warped/expected.png new file mode 100644 index 0000000000..d7f679d53f Binary files /dev/null and b/test/integration/render/tests/projection/globe/raster-warped/expected.png differ diff --git a/test/integration/render/tests/projection/globe/raster-warped/style.json b/test/integration/render/tests/projection/globe/raster-warped/style.json new file mode 100644 index 0000000000..b1be886e82 --- /dev/null +++ b/test/integration/render/tests/projection/globe/raster-warped/style.json @@ -0,0 +1,47 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that raster tiles are not warped under globe projection. Exact way pixels get rasterized doesn't matter, but the pattern should not be tilted." + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + -178.59375, + 84.92832092949962 + ], + "zoom": 8, + "projection": { + "type": "globe" + }, + "sources": { + "source": { + "type": "raster", + "tiles": [ + "local://tiles/checkerboard.png" + ], + "minzoom": 8, + "maxzoom": 8, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "red" + } + }, + { + "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/text-line-pole-to-pole/expected.png b/test/integration/render/tests/projection/globe/text-line-pole-to-pole/expected.png new file mode 100644 index 0000000000..82dc82b80f Binary files /dev/null and b/test/integration/render/tests/projection/globe/text-line-pole-to-pole/expected.png differ diff --git a/test/integration/render/tests/projection/globe/text-line-pole-to-pole/style.json b/test/integration/render/tests/projection/globe/text-line-pole-to-pole/style.json new file mode 100644 index 0000000000..4546aded70 --- /dev/null +++ b/test/integration/render/tests/projection/globe/text-line-pole-to-pole/style.json @@ -0,0 +1,73 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 0, + -85 + ], + [ + 0, + 85 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line", + "type": "line", + "source": "geojson", + "layout": { + "line-join": "round", + "line-cap": "round" + }, + "paint": { + "line-color": "green", + "line-width": 1 + } + }, + { + "id": "text", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line", + "text-field": "AAAA AAAAAA A AAAAA AAAA BB", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/text-pitched-rotated/expected.png b/test/integration/render/tests/projection/globe/text-pitched-rotated/expected.png new file mode 100644 index 0000000000..d2e3ef6ad5 Binary files /dev/null and b/test/integration/render/tests/projection/globe/text-pitched-rotated/expected.png differ diff --git a/test/integration/render/tests/projection/globe/text-pitched-rotated/style.json b/test/integration/render/tests/projection/globe/text-pitched-rotated/style.json new file mode 100644 index 0000000000..71988f2252 --- /dev/null +++ b/test/integration/render/tests/projection/globe/text-pitched-rotated/style.json @@ -0,0 +1,140 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "pitch": 30, + "bearing": 45, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [ + 0, + -80 + ], + [ + 0, + -60 + ], + [ + 0, + -40 + ], + [ + 0, + -20 + ], + [ + 0, + 0 + ], + [ + 0, + 20 + ], + [ + 0, + 40 + ], + [ + 0, + 60 + ], + [ + 0, + 80 + ], + [ + 80, + 0 + ], + [ + 60, + 0 + ], + [ + 40, + 0 + ], + [ + 20, + 0 + ], + [ + -20, + 0 + ], + [ + -40, + 0 + ], + [ + -60, + 0 + ], + [ + -80, + 0 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 3, + "circle-color": "blue" + } + }, + { + "id": "text", + "type": "symbol", + "source": "geojson", + "layout": { + "text-field": "TEST", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "left" + }, + "paint": { + "text-translate": [ + 10, + 0 + ], + "text-translate-anchor": "map" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/text-point-pole-to-pole/expected.png b/test/integration/render/tests/projection/globe/text-point-pole-to-pole/expected.png new file mode 100644 index 0000000000..0b6ccc650a Binary files /dev/null and b/test/integration/render/tests/projection/globe/text-point-pole-to-pole/expected.png differ diff --git a/test/integration/render/tests/projection/globe/text-point-pole-to-pole/style.json b/test/integration/render/tests/projection/globe/text-point-pole-to-pole/style.json new file mode 100644 index 0000000000..90d2a2ed10 --- /dev/null +++ b/test/integration/render/tests/projection/globe/text-point-pole-to-pole/style.json @@ -0,0 +1,108 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "width": 256 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 1, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "geometry": { + "type": "MultiPoint", + "coordinates": [ + [ + 0, + -80 + ], + [ + 0, + -60 + ], + [ + 0, + -40 + ], + [ + 0, + -20 + ], + [ + 0, + 0 + ], + [ + 0, + 20 + ], + [ + 0, + 40 + ], + [ + 0, + 60 + ], + [ + 0, + 80 + ] + ] + } + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 3, + "circle-color": "blue" + } + }, + { + "id": "text", + "type": "symbol", + "source": "geojson", + "layout": { + "text-field": "TEST", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "left", + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate": [ + 10, + 0 + ], + "text-translate-anchor": "map" + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/base/expected.png b/test/integration/render/tests/projection/globe/text-variable-anchor/base/expected.png new file mode 100644 index 0000000000..6ce9b11063 Binary files /dev/null and b/test/integration/render/tests/projection/globe/text-variable-anchor/base/expected.png differ diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/base/style.json b/test/integration/render/tests/projection/globe/text-variable-anchor/base/style.json new file mode 100644 index 0000000000..f3a2b1013a --- /dev/null +++ b/test/integration/render/tests/projection/globe/text-variable-anchor/base/style.json @@ -0,0 +1,563 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 2, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/pitched-and-rotated/expected.png b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched-and-rotated/expected.png new file mode 100644 index 0000000000..02544090f0 Binary files /dev/null and b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched-and-rotated/expected.png differ diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/pitched-and-rotated/style.json b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched-and-rotated/style.json new file mode 100644 index 0000000000..7bec60bbc0 --- /dev/null +++ b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched-and-rotated/style.json @@ -0,0 +1,569 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 15, + 0 + ], + "zoom": 2, + "pitch": 40, + "bearing": 60, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/pitched/expected.png b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched/expected.png new file mode 100644 index 0000000000..4a242f9a69 Binary files /dev/null and b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched/expected.png differ diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/pitched/style.json b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched/style.json new file mode 100644 index 0000000000..df5ceb9780 --- /dev/null +++ b/test/integration/render/tests/projection/globe/text-variable-anchor/pitched/style.json @@ -0,0 +1,564 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 2, + "pitch": 60, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/rotated/expected.png b/test/integration/render/tests/projection/globe/text-variable-anchor/rotated/expected.png new file mode 100644 index 0000000000..8405d251ba Binary files /dev/null and b/test/integration/render/tests/projection/globe/text-variable-anchor/rotated/expected.png differ diff --git a/test/integration/render/tests/projection/globe/text-variable-anchor/rotated/style.json b/test/integration/render/tests/projection/globe/text-variable-anchor/rotated/style.json new file mode 100644 index 0000000000..a4a64cf82f --- /dev/null +++ b/test/integration/render/tests/projection/globe/text-variable-anchor/rotated/style.json @@ -0,0 +1,564 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 512, + "width": 512 + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "zoom": 2, + "bearing": 60, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "projection": { "type": "globe" }, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -35.0, + 0.0 + ] + }, + "properties": { + "index": 0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -25.0, + 0.0 + ] + }, + "properties": { + "index": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -15.0, + 0.0 + ] + }, + "properties": { + "index": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -5.0, + 0.0 + ] + }, + "properties": { + "index": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.0, + 0.0 + ] + }, + "properties": { + "index": 4 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 15.0, + 0.0 + ] + }, + "properties": { + "index": 5 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 25.0, + 0.0 + ] + }, + "properties": { + "index": 6 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 35.0, + 0.0 + ] + }, + "properties": { + "index": 7 + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "circle-untranslated", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": "blue" + } + }, + { + "id": "circle_red_0", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_0", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 0 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_1", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_1", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 1 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_2", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_2", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 2 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_3", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_3", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 3 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_4", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_4", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 4 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_5", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_5", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 5 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_6", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "viewport", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_6", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 6 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "viewport", + "text-translate": [ + 10, + -20 + ] + } + }, + { + "id": "circle_red_7", + "type": "circle", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "paint": { + "circle-radius": 5, + "circle-color": "red", + "circle-translate-anchor": "map", + "circle-translate": [ + 10, + -20 + ] + } + }, + { + "id": "text_7", + "type": "symbol", + "source": "geojson", + "filter": [ + "==", + "index", + 7 + ], + "layout": { + "text-field": "AAA", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top" + ], + "text-size": 12, + "text-rotation-alignment": "map", + "text-pitch-alignment": "map" + }, + "paint": { + "text-translate-anchor": "map", + "text-translate": [ + 10, + -20 + ] + } + } + ] +} \ 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..1ebf3d5456 --- /dev/null +++ b/test/integration/render/tests/projection/globe/zoom-transition/style.json @@ -0,0 +1,44 @@ +{ + "version": 8, + "metadata": { + "test": { + "description": "Tests that globe projection transitions to mercator at high zoom levels.", + "height": 256, + "operations": [ + [ + "sleep", + 1250 + ] + ] + } + }, + "sky": { + "atmosphere-blend": 0.0 + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 16, + "projection": { "type": "globe" }, + "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..65e1afdd0a --- /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." + } + }, + "center": [ + 15.0, + 0.0 + ], + "zoom": 1, + "projection": { "type": "mercator" }, + "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 diff --git a/test/integration/render/tests/regressions/mapbox-gl-js#2762/expected-win-2.png b/test/integration/render/tests/regressions/mapbox-gl-js#2762/expected-win-2.png new file mode 100644 index 0000000000..4b3a73f633 Binary files /dev/null and b/test/integration/render/tests/regressions/mapbox-gl-js#2762/expected-win-2.png differ diff --git a/test/integration/render/tests/symbol-visibility/visible/expected-debian.png b/test/integration/render/tests/symbol-visibility/visible/expected-debian.png new file mode 100644 index 0000000000..480f4f3f5d Binary files /dev/null and b/test/integration/render/tests/symbol-visibility/visible/expected-debian.png differ diff --git a/test/integration/render/tests/symbol-visibility/visible/expected.png b/test/integration/render/tests/symbol-visibility/visible/expected.png index fcb921de3d..a05f1b4dad 100644 Binary files a/test/integration/render/tests/symbol-visibility/visible/expected.png and b/test/integration/render/tests/symbol-visibility/visible/expected.png differ diff --git a/test/integration/render/tests/terrain-shading/default/expected_debian.png b/test/integration/render/tests/terrain-shading/default/expected_debian.png new file mode 100644 index 0000000000..6124484037 Binary files /dev/null and b/test/integration/render/tests/terrain-shading/default/expected_debian.png differ diff --git a/test/integration/render/tests/terrain/fill-extrusion-multiple/expected.png b/test/integration/render/tests/terrain/fill-extrusion-multiple/expected.png index 0ef7340bb7..798be7e3b7 100644 Binary files a/test/integration/render/tests/terrain/fill-extrusion-multiple/expected.png and b/test/integration/render/tests/terrain/fill-extrusion-multiple/expected.png differ diff --git a/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png b/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png index befb56ae95..9799678a53 100644 Binary files a/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png and b/test/integration/render/tests/terrain/fill-extrusion-multipolygon/expected.png differ diff --git a/test/unit/lib/mesh_utils.ts b/test/unit/lib/mesh_utils.ts new file mode 100644 index 0000000000..8918cffeb3 --- /dev/null +++ b/test/unit/lib/mesh_utils.ts @@ -0,0 +1,229 @@ +import {EXTENT} from '../../../src/data/extent'; +import {clamp} from '../../../src/util/util'; + +export type SimpleSegment = { + vertexOffset: number; + primitiveOffset: number; + primitiveLength: number; +}; + +export type SimpleMesh = { + segmentsTriangles: Array; + segmentsLines: Array; + vertices: Array; + indicesTriangles: Array; + indicesLines: Array; +} + +/** + * Generates a simple grid mesh that has `size` by `size` quads. + */ +export function getGridMesh(size: number): SimpleMesh { + const vertices = []; + const indicesTriangles = []; + const indicesLines = []; + + const verticesPerAxis = size + 1; + + // Generate vertices + for (let y = 0; y < verticesPerAxis; y++) { + for (let x = 0; x < verticesPerAxis; x++) { + vertices.push(x, y); + } + } + + // Generate indices + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const i00 = (y * verticesPerAxis) + x; + const i10 = (y * verticesPerAxis) + (x + 1); + const i01 = ((y + 1) * verticesPerAxis) + x; + const i11 = ((y + 1) * verticesPerAxis) + (x + 1); + indicesTriangles.push(i00, i11, i10); + indicesTriangles.push(i00, i01, i11); + } + } + + // Generate lines + + // Top + for (let i = 0; i < size; i++) { + indicesLines.push( + i, + i + 1 + ); + } + // Bottom + for (let i = 0; i < size; i++) { + indicesLines.push( + verticesPerAxis * size + i, + verticesPerAxis * size + i + 1 + ); + } + // Left + for (let i = 0; i < size; i++) { + indicesLines.push( + i * verticesPerAxis, + (i + 1) * verticesPerAxis + ); + } + // Right + for (let i = 0; i < size; i++) { + indicesLines.push( + i * verticesPerAxis + size, + (i + 1) * verticesPerAxis + size + ); + } + + return { + segmentsTriangles: [{ + primitiveLength: indicesTriangles.length / 3, + primitiveOffset: 0, + vertexOffset: 0, + }], + segmentsLines: [{ + primitiveLength: indicesLines.length / 2, + primitiveOffset: 0, + vertexOffset: 0, + }], + vertices, + indicesTriangles, + indicesLines + }; +} + +// https://stackoverflow.com/a/47593316 +// https://gist.github.com/tommyettinger/46a874533244883189143505d203312c?permalink_comment_id=4365431#gistcomment-4365431 +function splitmix32(a) { + return function() { + a |= 0; + a = a + 0x9e3779b9 | 0; + let t = a ^ a >>> 16; + t = Math.imul(t, 0x21f0aaad); + t = t ^ t >>> 15; + t = Math.imul(t, 0x735a2d97); + return ((t = t ^ t >>> 15) >>> 0) / 4294967296; + }; +} + +/** + * Generates a mesh with the vertices of a grid, but random triangles and lines. + */ +export function getGridMeshRandom(size: number, triangleCount: number, lineCount: number): SimpleMesh { + const vertices = []; + const indicesTriangles = []; + const indicesLines = []; + + const verticesPerAxis = size + 1; + + // Generate vertices + for (let y = 0; y < verticesPerAxis; y++) { + for (let x = 0; x < verticesPerAxis; x++) { + vertices.push(x, y); + } + } + + const vertexCount = vertices.length / 2; + const random = splitmix32(0x0badf00d); + + for (let i = 0; i < triangleCount; i++) { + const i0 = clamp(Math.floor(random() * vertexCount), 0, vertexCount); + let i1 = clamp(Math.floor(random() * vertexCount), 0, vertexCount); + let i2 = clamp(Math.floor(random() * vertexCount), 0, vertexCount); + while (i1 === i0) { + i1 = (i1 + 1) % vertexCount; + } + while (i2 === i0 || i2 === i1) { + i2 = (i2 + 1) % vertexCount; + } + indicesTriangles.push(i0, i1, i2); + } + + for (let i = 0; i < lineCount; i++) { + const i0 = clamp(Math.floor(random() * vertexCount), 0, vertexCount); + let i1 = clamp(Math.floor(random() * vertexCount), 0, vertexCount); + while (i1 === i0) { + i1 = (i1 + 1) % vertexCount; + } + indicesLines.push(i0, i1); + } + + return { + segmentsTriangles: [{ + primitiveLength: indicesTriangles.length / 3, + primitiveOffset: 0, + vertexOffset: 0, + }], + segmentsLines: [{ + primitiveLength: indicesLines.length / 2, + primitiveOffset: 0, + vertexOffset: 0, + }], + vertices, + indicesTriangles, + indicesLines + }; +} + +/** + * Returns a SVG image (as string) that displays the supplied triangles and lines. Only vertices used by the triangles are included in the svg. + * @param flattened - Array of flattened vertex coordinates. + * @param triangles - Array of triangle indices. + * @param edges - List of arrays of edge indices. Every pair of indices forms a line. A single triangle would look like `[[0 1 1 2 2 0]]`. + * @returns SVG image as string. + */ +export function getDebugSvg(flattened: Array, triangles?: Array, edges?: Array>, granularity: number = 1): string { + const svg = []; + + const cellSize = EXTENT / granularity; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (let i = 0; i < triangles.length; i++) { + const x = flattened[triangles[i] * 2]; + const y = flattened[triangles[i] * 2 + 1]; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + svg.push(``); + + if (triangles) { + for (let i = 0; i < triangles.length; i += 3) { + const i0 = triangles[i]; + const i1 = triangles[i + 1]; + const i2 = triangles[i + 2]; + + for (const index of [i0, i1, i2]) { + const x = flattened[index * 2]; + const y = flattened[index * 2 + 1]; + const isOnCellEdge = (x % cellSize === 0) || (y % cellSize === 0); + svg.push(``); + svg.push(`${(index).toString()}`); + } + + for (const edge of [[i0, i1], [i1, i2], [i2, i0]]) { + svg.push(``); + } + } + } + + if (edges) { + for (const edgeList of edges) { + for (let i = 0; i < edgeList.length; i += 2) { + svg.push(``); + svg.push(``); + svg.push(``); + } + } + } + + svg.push(''); + + return svg.join(''); +}