diff --git a/build/generate-style-spec.ts b/build/generate-style-spec.ts index be01b4a7b90..ae39d91b1dc 100644 --- a/build/generate-style-spec.ts +++ b/build/generate-style-spec.ts @@ -197,6 +197,8 @@ ${objectDeclaration('StyleSpecification', spec.$root)} ${objectDeclaration('LightSpecification', spec.light)} +${objectDeclaration('TerrainSpecification', spec.terrain)} + ${spec.source.map(key => objectDeclaration(sourceTypeName(key), spec[key])).join('\n\n')} export type SourceSpecification = diff --git a/debug/terrain.html b/debug/terrain.html new file mode 100644 index 00000000000..e45ca8ba998 --- /dev/null +++ b/debug/terrain.html @@ -0,0 +1,93 @@ + + + + + MapLibre GL JS debug page for terrian + + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/src/css/maplibre-gl.css b/src/css/maplibre-gl.css index 5e8fe3f0c90..6e3cf61d30f 100644 --- a/src/css/maplibre-gl.css +++ b/src/css/maplibre-gl.css @@ -308,6 +308,26 @@ } } +@svg-load ctrl-terrain url("svg/maplibregl-ctrl-terrain.svg") { + fill: #333; + #stroke { display: none; } +} + +@svg-load ctrl-terrain-enabled url("svg/maplibregl-ctrl-terrain.svg") { + fill: #33b5e5; + #stroke { display: none; } +} + +.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon, +.mapboxgl-ctrl button.mapboxgl-ctrl-terrain .mapboxgl-ctrl-icon { + background-image: svg-inline(ctrl-terrain); +} + +.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon, +.mapboxgl-ctrl button.mapboxgl-ctrl-terrain-enabled .mapboxgl-ctrl-icon { + background-image: svg-inline(ctrl-terrain-enabled); +} + @svg-load ctrl-geolocate url("svg/maplibregl-ctrl-geolocate.svg") { fill: #333; #stroke { display: none; } diff --git a/src/css/svg/maplibregl-ctrl-terrain.svg b/src/css/svg/maplibregl-ctrl-terrain.svg new file mode 100644 index 00000000000..af8fefe7a58 --- /dev/null +++ b/src/css/svg/maplibregl-ctrl-terrain.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/data/bucket/fill_extrusion_attributes.ts b/src/data/bucket/fill_extrusion_attributes.ts index dcb728e73f8..e4e2936ce90 100644 --- a/src/data/bucket/fill_extrusion_attributes.ts +++ b/src/data/bucket/fill_extrusion_attributes.ts @@ -5,5 +5,9 @@ const layout = createLayout([ {name: 'a_normal_ed', components: 4, type: 'Int16'}, ], 4); +export const centroidAttributes = createLayout([ + {name: 'a_centroid', components: 2, type: 'Int16'} +], 4); + export default layout; export const {members, size, alignment} = layout; diff --git a/src/data/bucket/fill_extrusion_bucket.ts b/src/data/bucket/fill_extrusion_bucket.ts index 6d47ff34684..13d81d4467a 100644 --- a/src/data/bucket/fill_extrusion_bucket.ts +++ b/src/data/bucket/fill_extrusion_bucket.ts @@ -1,6 +1,6 @@ -import {FillExtrusionLayoutArray} from '../array_types.g'; +import {FillExtrusionLayoutArray, PosArray} from '../array_types.g'; -import {members as layoutAttributes} from './fill_extrusion_attributes'; +import {members as layoutAttributes, centroidAttributes} from './fill_extrusion_attributes'; import SegmentVector from '../segment'; import {ProgramConfigurationSet} from '../program_configuration'; import {TriangleIndexArray} from '../index_array_type'; @@ -63,6 +63,9 @@ class FillExtrusionBucket implements Bucket { layoutVertexArray: FillExtrusionLayoutArray; layoutVertexBuffer: VertexBuffer; + centroidVertexArray: PosArray; + centroidVertexBuffer: VertexBuffer; + indexArray: TriangleIndexArray; indexBuffer: IndexBuffer; @@ -81,11 +84,11 @@ class FillExtrusionBucket implements Bucket { this.hasPattern = false; this.layoutVertexArray = new FillExtrusionLayoutArray(); + this.centroidVertexArray = new PosArray(); this.indexArray = new TriangleIndexArray(); this.programConfigurations = new ProgramConfigurationSet(options.layers, options.zoom); this.segments = new SegmentVector(); this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id); - } populate(features: Array, options: PopulateParameters, canonical: CanonicalTileID) { @@ -131,7 +134,7 @@ class FillExtrusionBucket implements Bucket { } isEmpty() { - return this.layoutVertexArray.length === 0; + return this.layoutVertexArray.length === 0 && this.centroidVertexArray.length === 0; } uploadPending() { @@ -141,6 +144,7 @@ class FillExtrusionBucket implements Bucket { upload(context: Context) { if (!this.uploaded) { this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, layoutAttributes); + this.centroidVertexBuffer = context.createVertexBuffer(this.centroidVertexArray, centroidAttributes.members, true); this.indexBuffer = context.createIndexBuffer(this.indexArray); } this.programConfigurations.upload(context); @@ -153,9 +157,11 @@ class FillExtrusionBucket implements Bucket { this.indexBuffer.destroy(); this.programConfigurations.destroy(); this.segments.destroy(); + this.centroidVertexBuffer.destroy(); } addFeature(feature: BucketFeature, geometry: Array>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) { + const centroid = {x: 0, y: 0, vertexCount: 0}; for (const polygon of classifyRings(geometry, EARCUT_MAX_RINGS)) { let numVertices = 0; for (const ring of polygon) { @@ -191,11 +197,17 @@ class FillExtrusionBucket implements Bucket { 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; edgeDistance += dist; 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 bottomRight = segment.vertexLength; @@ -212,6 +224,7 @@ class FillExtrusionBucket implements Bucket { } } } + } if (segment.vertexLength + numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) { @@ -240,10 +253,14 @@ class FillExtrusionBucket implements Bucket { const p = ring[i]; addVertex(this.layoutVertexArray, p.x, p.y, 0, 0, 1, 1, 0); + centroid.x += p.x; + centroid.y += p.y; + centroid.vertexCount += 1; flattened.push(p.x); flattened.push(p.y); } + } const indices = earcut(flattened, holeIndices); @@ -261,6 +278,13 @@ class FillExtrusionBucket implements Bucket { segment.vertexLength += numVertices; } + // remember polygon centroid to calculate elevation in GPU + for (let i = 0; i < centroid.vertexCount; i++) { + this.centroidVertexArray.emplaceBack( + Math.floor(centroid.x / centroid.vertexCount), + Math.floor(centroid.y / centroid.vertexCount) + ); + } this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, imagePositions, canonical); } } diff --git a/src/data/dem_data.test.ts b/src/data/dem_data.test.ts index 4380c0a7c57..9c68a9c4068 100644 --- a/src/data/dem_data.test.ts +++ b/src/data/dem_data.test.ts @@ -222,13 +222,26 @@ describe('DEMData is correctly serialized and deserialized', () => { const dem0 = new DEMData('0', imageData0, 'mapbox'); const serialized = serialize(dem0); + // calculate min/max values + let min = Number.MAX_SAFE_INTEGER; + let max = Number.MIN_SAFE_INTEGER; + for (let x = 0; x < 4; x++) { + for (let y = 0; y < 4; y++) { + const ele = dem0.get(x, y); + if (ele > max) max = ele; + if (ele < min) min = ele; + } + } + expect(serialized).toEqual({ $name: 'DEMData', uid: '0', dim: 4, stride: 6, data: dem0.data, - encoding: 'mapbox' + encoding: 'mapbox', + max, + min, }); const transferrables = []; diff --git a/src/data/dem_data.ts b/src/data/dem_data.ts index cddb88be2e3..a6d0982ff9d 100644 --- a/src/data/dem_data.ts +++ b/src/data/dem_data.ts @@ -18,6 +18,8 @@ export default class DEMData { data: Uint32Array; stride: number; dim: number; + min: number; + max: number; encoding: 'mapbox' | 'terrarium'; // RGBAImage data has uniform 1px padding on all sides: square tile edge size defines stride @@ -52,6 +54,17 @@ export default class DEMData { this.data[this._idx(dim, -1)] = this.data[this._idx(dim - 1, 0)]; this.data[this._idx(-1, dim)] = this.data[this._idx(0, dim - 1)]; this.data[this._idx(dim, dim)] = this.data[this._idx(dim - 1, dim - 1)]; + + // calculate min/max values + this.min = Number.MAX_SAFE_INTEGER; + this.max = Number.MIN_SAFE_INTEGER; + for (let x = 0; x < dim; x++) { + for (let y = 0; y < dim; y++) { + const ele = this.get(x, y); + if (ele > this.max) this.max = ele; + if (ele < this.min) this.min = ele; + } + } } get(x: number, y: number) { diff --git a/src/geo/transform.test.ts b/src/geo/transform.test.ts index faab19fc50d..2969ea3efaf 100644 --- a/src/geo/transform.test.ts +++ b/src/geo/transform.test.ts @@ -3,6 +3,7 @@ import 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'; describe('transform', () => { test('creates a transform', () => { @@ -340,7 +341,6 @@ describe('transform', () => { }); test('maintains high float precision when calculating matrices', () => { - const transform = new Transform(0, 22, 0, 60, true); transform.resize(200.25, 200.25); transform.zoom = 20.25; @@ -352,4 +352,59 @@ describe('transform', () => { expect(transform.glCoordMatrix[0].toString().length).toBeGreaterThan(10); expect(transform.maxPitchScaleFactor()).toBeCloseTo(2.366025418080343, 10); }); + + test('recalcuateZoom', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.elevation = 200; + transform.center = new LngLat(10.0, 50.0); + transform.zoom = 14; + transform.resize(512, 512); + + // expect same values because of no elevation change + transform.getElevation = () => 200; + transform.recalculateZoom(null); + expect(transform.zoom).toBe(14); + + // expect new zoom because of elevation change + transform.getElevation = () => 400; + transform.recalculateZoom(null); + expect(transform.zoom).toBe(14.127997275621933); + expect(transform.elevation).toBe(400); + expect(transform._center.lng).toBe(10.00000000000071); + expect(transform._center.lat).toBe(50.00000000000017); + }); + + test('pointCoordinate with terrain when returning null should fall back to 2D', () => { + const transform = new Transform(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); + + 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); + transform.resize(500, 500); + + transform.pitch = 60; + expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.pointLocation(new Point(0, 0)).toArray()); + + transform.pitch = 75; + const top = Math.max(0, transform.height / 2 - transform.getHorizon()); + expect(top).toBeCloseTo(79.1823898251593, 10); + expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.pointLocation(new Point(0, top)).toArray()); + }); + }); diff --git a/src/geo/transform.ts b/src/geo/transform.ts index 06991e9f603..b6258942bb2 100644 --- a/src/geo/transform.ts +++ b/src/geo/transform.ts @@ -5,12 +5,13 @@ import Point from '@mapbox/point-geometry'; import {wrap, clamp} from '../util/util'; import {number as interpolate} from '../style-spec/util/interpolate'; import EXTENT from '../data/extent'; -import {vec4, mat4, mat2, vec2} from 'gl-matrix'; +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'; /** * A single transform, generally used for a single tile to be @@ -31,14 +32,17 @@ class Transform { zoomFraction: number; pixelsToGLUnits: [number, number]; cameraToCenterDistance: number; + cameraToSeaLevelDistance: number; mercatorMatrix: mat4; projMatrix: mat4; invProjMatrix: mat4; alignedProjMatrix: mat4; pixelMatrix: mat4; + pixelMatrix3D: mat4; pixelMatrixInverse: mat4; glCoordMatrix: mat4; labelPlaneMatrix: mat4; + freezeElevation: boolean; _fov: number; _pitch: number; _zoom: number; @@ -49,6 +53,8 @@ class Transform { _minPitch: number; _maxPitch: number; _center: LngLat; + _elevation: number; + _pixelPerMeter: number; _edgeInsets: EdgeInsets; _constraining: boolean; _posMatrixCache: {[_: string]: mat4}; @@ -57,6 +63,7 @@ class Transform { constructor(minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) { this.tileSize = 512; // constant this.maxValidLatitude = 85.051129; // constant + this.freezeElevation = false; this._renderWorldCopies = renderWorldCopies === undefined ? true : !!renderWorldCopies; this._minZoom = minZoom || 0; @@ -70,6 +77,7 @@ class Transform { 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; @@ -87,6 +95,7 @@ class Transform { clone.width = this.width; clone.height = this.height; clone._center = this._center; + clone._elevation = this._elevation; clone.zoom = this.zoom; clone.angle = this.angle; clone._fov = this._fov; @@ -207,6 +216,14 @@ class Transform { this._calcMatrices(); } + 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; @@ -322,6 +339,7 @@ class Transform { roundZoom?: boolean; reparseOverscaled?: boolean; renderWorldCopies?: boolean; + terrain?: Terrain; } ): Array { let z = this.coveringZoomLevel(options); @@ -330,23 +348,24 @@ class Transform { 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.invProjMatrix, 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 (this.pitch <= 60.0 && this._edgeInsets.top < 0.1) + 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 - const radiusOfMaxLvlLodInTiles = 3; + // 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 { - // All tiles are on zero elevation plane => z difference is zero aabb: new Aabb([wrap * numTiles, 0, 0], [(wrap + 1) * numTiles, numTiles, 0]), zoom: 0, x: 0, @@ -388,8 +407,9 @@ class Transform { fullyVisible = intersectResult === 2; } - const distanceX = it.aabb.distanceX(centerPoint); - const distanceY = it.aabb.distanceY(centerPoint); + 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. @@ -401,9 +421,12 @@ class Transform { // 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]) + 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; } @@ -411,8 +434,22 @@ class Transform { for (let i = 0; i < 4; i++) { const childX = (x << 1) + (i % 2); const childY = (y << 1) + (i >> 1); - - stack.push({aabb: it.aabb.quadrant(i), zoom: it.zoom + 1, x: childX, y: childY, wrap: it.wrap, fullyVisible}); + 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 tile = options.terrain.getTerrainData(tileID).tile; + let minElevation = this.elevation, maxElevation = this.elevation; + if (tile && tile.dem) { + minElevation = tile.dem.min * options.terrain.exaggeration; + maxElevation = tile.dem.max * options.terrain.exaggeration; + } + 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}); } } @@ -446,6 +483,72 @@ class Transform { get point(): Point { return this.project(this.center); } + /** + * Updates the center-elevation value unless freezeElevation is activated. + * @param terrain the terrain + */ + updateElevation(terrain?: Terrain) { + if (this.freezeElevation) return; + this.elevation = terrain ? this.getElevation(this._center, terrain) : 0; + } + + /** + * get the elevation from terrain for the current zoomlevel. + * @param lnglat the location + * @param terrain the terrain + * @returns {Number} elevation in meters + */ + getElevation(lnglat: LngLat, terrain: Terrain) { + const merc = MercatorCoordinate.fromLngLat(lnglat); + const worldSize = (1 << this.tileZoom) * EXTENT; + const mercX = merc.x * worldSize, mercY = merc.y * worldSize; + const tileX = Math.floor(mercX / EXTENT), tileY = Math.floor(mercY / EXTENT); + const tileID = new OverscaledTileID(this.tileZoom, 0, this.tileZoom, tileX, tileY); + return terrain.getElevation(tileID, mercX % EXTENT, mercY % EXTENT, EXTENT); + } + + /** + * get the camera position in LngLat and altitudes in meter + * @returns {Object} 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. + * freezeElevtion 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} terrain the terrain + */ + recalculateZoom(terrain: Terrain) { + // find position the camera is looking on + const center = this.pointLocation(this.centerPoint, terrain); + const elevation = this.getElevation(center, terrain); + const deltaElevation = this.elevation - elevation; + if (!deltaElevation) return; + + // calculate mercator distance between camera & target + const cameraPosition = this.getCameraPosition(); + const camera = MercatorCoordinate.fromLngLat(cameraPosition.lngLat, cameraPosition.altitude); + const target = MercatorCoordinate.fromLngLat(center, elevation); + const dx = camera.x - target.x, dy = camera.y - target.y, dz = camera.z - target.z; + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // from this distance we calculate the new zoomlevel + const zoom = this.scaleZoom(this.cameraToCenterDistance / distance / this.tileSize); + + // 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); @@ -462,31 +565,35 @@ class Transform { /** * Given a location, return the screen point that corresponds to it * @param {LngLat} lnglat location + * @param {Terrain} terrain optional terrain * @returns {Point} screen point * @private */ - locationPoint(lnglat: LngLat) { - return this.coordinatePoint(this.locationCoordinate(lnglat)); + locationPoint(lnglat: LngLat, terrain?: Terrain): Point { + return terrain ? + this.coordinatePoint(this.locationCoordinate(lnglat), this.getElevation(lnglat, terrain), this.pixelMatrix3D) : + this.coordinatePoint(this.locationCoordinate(lnglat)); } /** * Given a point on screen, return its lnglat * @param {Point} p screen point + * @param {Terrain} terrain optional terrain * @returns {LngLat} lnglat location * @private */ - pointLocation(p: Point) { - return this.coordinateLocation(this.pointCoordinate(p)); + 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 this transform's zoom level. * @param {LngLat} lnglat - * @returns {Coordinate} + * @returns {MercatorCoordinate} * @private */ - locationCoordinate(lnglat: LngLat) { + locationCoordinate(lnglat: LngLat): MercatorCoordinate { return MercatorCoordinate.fromLngLat(lnglat); } @@ -496,11 +603,27 @@ class Transform { * @returns {LngLat} lnglat * @private */ - coordinateLocation(coord: MercatorCoordinate) { - return coord.toLngLat(); + coordinateLocation(coord: MercatorCoordinate): LngLat { + return coord && coord.toLngLat(); } - pointCoordinate(p: Point) { + /** + * Given a Point, return its mercator coordinate. + * @param {Point} p the point + * @param {Terrain} terrain optional terrain + * @returns {LngLat} lnglat + * @private + */ + 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; + } + } + + // calcuate 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 @@ -531,12 +654,14 @@ class Transform { /** * Given a coordinate, return the screen point that corresponds to it * @param {Coordinate} coord + * @params {number} elevation default = 0 + * @params {mat4} pixelMatrix, default = this.pixelMatrix * @returns {Point} screen point * @private */ - coordinatePoint(coord: MercatorCoordinate) { - const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1] as any; - vec4.transformMat4(p, p, this.pixelMatrix); + coordinatePoint(coord: MercatorCoordinate, elevation: number = 0, pixelMatrix = this.pixelMatrix): Point { + const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as any; + vec4.transformMat4(p, p, pixelMatrix); return new Point(p[0] / p[3], p[1] / p[3]); } @@ -546,9 +671,10 @@ class Transform { * @returns {LngLatBounds} 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, 0))) - .extend(this.pointLocation(new Point(this.width, 0))) + .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))); } @@ -564,6 +690,16 @@ class Transform { 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 {number} 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 {LngLatBounds} bounds A {@link LngLatBounds} object describing the new geographic boundaries of the map. @@ -682,7 +818,23 @@ class Transform { 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 + this.cameraToSeaLevelDistance = this.cameraToCenterDistance + this._elevation * this._pixelPerMeter / Math.cos(this._pitch); // 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. @@ -690,14 +842,20 @@ class Transform { // (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) * this.cameraToCenterDistance / Math.sin(clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01)); - const point = this.point; - const x = point.x, y = point.y; + const topHalfSurfaceDistance = Math.sin(fovAboveCenter) * this.cameraToSeaLevelDistance / 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) * this.cameraToSeaLevelDistance / Math.sin(clamp(Math.PI - groundAngle - fovCenterToHorizon, 0.01, Math.PI - 0.01)); // Calculate z distance of the farthest fragment that should be rendered. - const furthestDistance = Math.cos(Math.PI / 2 - this._pitch) * topHalfSurfaceDistance + this.cameraToCenterDistance; + const furthestDistance = Math.cos(Math.PI / 2 - this._pitch) * topHalfSurfaceDistance + this.cameraToSeaLevelDistance; + const furthestDistanceHorizon = Math.cos(Math.PI / 2 - this._pitch) * topHalfSurfaceDistanceHorizon + this.cameraToSeaLevelDistance; + // Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance` - const farZ = furthestDistance * 1.01; + const farZ = Math.min(furthestDistance, furthestDistanceHorizon) * 1.01; // The larger the value of nearZ is // - the more depth precision is available for features (good) @@ -709,10 +867,10 @@ class Transform { const nearZ = this.height / 50; // matrix for conversion from location to GL coordinates (-1 .. 1) - let m = new Float64Array(16) as any; + m = new Float64Array(16) as any; mat4.perspective(m, this._fov, this.width / this.height, nearZ, farZ); - //Apply center of perspective offset + // Apply center of perspective offset m[8] = -offset.x * 2 / this.width; m[9] = offset.y * 2 / this.height; @@ -727,10 +885,18 @@ class Transform { 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, mercatorZfromAltitude(1, this.center.lat) * this.worldSize]); + mat4.scale(m, m, [1, 1, this._pixelPerMeter]); + + // matrix for conversion from location to screen coordinates in 2D + this.pixelMatrix = mat4.multiply(new Float64Array(16) as any, this.labelPlaneMatrix, m); + // matrix for conversion from location to GL coordinates (-1 .. 1) + mat4.translate(m, m, [0, 0, -this.elevation]); // elevate camera over terrain this.projMatrix = m; - this.invProjMatrix = mat4.invert([] as any, this.projMatrix); + this.invProjMatrix = mat4.invert([] as any, m); + + // matrix for conversion from location to screen coordinates in 2D + 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 @@ -746,20 +912,6 @@ class Transform { mat4.translate(alignedM, alignedM, [ dx > 0.5 ? dx - 1 : dx, dy > 0.5 ? dy - 1 : dy, 0 ]); this.alignedProjMatrix = alignedM; - m = mat4.create(); - mat4.scale(m, m, [this.width / 2, -this.height / 2, 1]); - mat4.translate(m, m, [1, -1, 0]); - this.labelPlaneMatrix = m; - - m = mat4.create(); - 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; - - // matrix for conversion from location to screen coordinates - this.pixelMatrix = mat4.multiply(new Float64Array(16) as any, this.labelPlaneMatrix, this.projMatrix); - // inverse matrix for conversion from screen coordinaes to location m = mat4.invert(new Float64Array(16) as any, this.pixelMatrix); if (!m) throw new Error('failed to invert matrix'); diff --git a/src/index.ts b/src/index.ts index 5aa5064373a..386118ab67e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import AttributionControl from './ui/control/attribution_control'; import LogoControl from './ui/control/logo_control'; import ScaleControl from './ui/control/scale_control'; import FullscreenControl from './ui/control/fullscreen_control'; +import TerrainControl from './ui/control/terrain_control'; import Popup from './ui/popup'; import Marker from './ui/marker'; import Style from './style/style'; @@ -46,6 +47,7 @@ const exported = { LogoControl, ScaleControl, FullscreenControl, + TerrainControl, Popup, Marker, Style, diff --git a/src/render/draw_background.ts b/src/render/draw_background.ts index e54905b287a..116beae5f97 100644 --- a/src/render/draw_background.ts +++ b/src/render/draw_background.ts @@ -9,10 +9,11 @@ import { import type Painter from './painter'; import type SourceCache from '../source/source_cache'; import type BackgroundStyleLayer from '../style/style_layer/background_style_layer'; +import {OverscaledTileID} from '../source/tile_id'; export default drawBackground; -function drawBackground(painter: Painter, sourceCache: SourceCache, layer: BackgroundStyleLayer) { +function drawBackground(painter: Painter, sourceCache: SourceCache, layer: BackgroundStyleLayer, coords?: Array) { const color = layer.paint.get('background-color'); const opacity = layer.paint.get('background-opacity'); @@ -31,10 +32,8 @@ function drawBackground(painter: Painter, sourceCache: SourceCache, layer: Backg const stencilMode = StencilMode.disabled; const depthMode = painter.depthModeForSublayer(0, pass === 'opaque' ? DepthMode.ReadWrite : DepthMode.ReadOnly); const colorMode = painter.colorModeForRenderPass(); - const program = painter.useProgram(image ? 'backgroundPattern' : 'background'); - - const tileIDs = transform.coveringTiles({tileSize}); + const tileIDs = coords ? coords : transform.coveringTiles({tileSize, terrain: painter.style.terrain}); if (image) { context.activeTexture.set(gl.TEXTURE0); @@ -43,13 +42,14 @@ function drawBackground(painter: Painter, sourceCache: SourceCache, layer: Backg const crossfade = layer.getCrossfadeParameters(); for (const tileID of tileIDs) { - const matrix = painter.transform.calculatePosMatrix(tileID.toUnwrapped()); + const matrix = coords ? tileID.posMatrix : painter.transform.calculatePosMatrix(tileID.toUnwrapped()); const uniformValues = image ? backgroundPatternUniformValues(matrix, opacity, painter, image, {tileID, tileSize}, crossfade) : backgroundUniformValues(matrix, opacity, color); + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(tileID); program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, layer.id, painter.tileExtentBuffer, + uniformValues, terrainData, layer.id, painter.tileExtentBuffer, painter.quadTriangleIndexBuffer, painter.tileExtentSegments); } } diff --git a/src/render/draw_circle.ts b/src/render/draw_circle.ts index 2ee94bbbb61..6e376f61a5d 100644 --- a/src/render/draw_circle.ts +++ b/src/render/draw_circle.ts @@ -15,6 +15,7 @@ import type VertexBuffer from '../gl/vertex_buffer'; 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'; export default drawCircles; @@ -24,6 +25,7 @@ type TileRenderState = { layoutVertexBuffer: VertexBuffer; indexBuffer: IndexBuffer; uniformValues: UniformValues; + terrainData: TerrainData; }; type SegmentsTileRenderState = { @@ -66,6 +68,7 @@ function drawCircles(painter: Painter, sourceCache: SourceCache, layer: CircleSt const program = painter.useProgram('circle', programConfiguration); const layoutVertexBuffer = bucket.layoutVertexBuffer; const indexBuffer = bucket.indexBuffer; + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(coord); const uniformValues = circleUniformValues(painter, coord, tile, layer); const state: TileRenderState = { @@ -74,6 +77,7 @@ function drawCircles(painter: Painter, sourceCache: SourceCache, layer: CircleSt layoutVertexBuffer, indexBuffer, uniformValues, + terrainData }; if (sortFeaturesByKey) { @@ -100,11 +104,11 @@ function drawCircles(painter: Painter, sourceCache: SourceCache, layer: CircleSt } for (const segmentsState of segmentsRenderStates) { - const {programConfiguration, program, layoutVertexBuffer, indexBuffer, uniformValues} = segmentsState.state; + const {programConfiguration, program, layoutVertexBuffer, indexBuffer, uniformValues, terrainData} = segmentsState.state; const segments = segmentsState.segments; program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, layer.id, + uniformValues, terrainData, 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 37cca5f5fe7..da85a777b8c 100644 --- a/src/render/draw_collision_debug.ts +++ b/src/render/draw_collision_debug.ts @@ -22,6 +22,7 @@ type TileBatch = { circleOffset: number; transform: mat4; invTransform: mat4; + coord: OverscaledTileID; }; let quadTriangles: QuadTriangleArray; @@ -60,7 +61,8 @@ function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, layer: S circleArray, circleOffset, transform, - invTransform + invTransform, + coord }); circleCount += circleArray.length / 4; // 4 values per circle @@ -75,6 +77,7 @@ function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, layer: S posMatrix, painter.transform, tile), + painter.style.terrain && painter.style.terrain.getTerrainData(coord), layer.id, buffers.layoutVertexBuffer, buffers.indexBuffer, buffers.segments, null, painter.transform.zoom, null, null, buffers.collisionVertexBuffer); @@ -132,6 +135,7 @@ function drawCollisionDebug(painter: Painter, sourceCache: SourceCache, layer: S painter.colorModeForRenderPass(), CullFaceMode.disabled, uniforms, + painter.style.terrain && painter.style.terrain.getTerrainData(batch.coord), layer.id, vertexBuffer, indexBuffer, diff --git a/src/render/draw_debug.ts b/src/render/draw_debug.ts index 23250c50fd3..210e9858fc1 100644 --- a/src/render/draw_debug.ts +++ b/src/render/draw_debug.ts @@ -77,15 +77,12 @@ function drawDebugTile(painter, sourceCache, coord: OverscaledTileID) { const stencilMode = StencilMode.disabled; const colorMode = painter.colorModeForRenderPass(); const id = '$debug'; + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(coord); context.activeTexture.set(gl.TEXTURE0); // Bind the empty texture for drawing outlines painter.emptyTexture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); - program.draw(context, gl.LINE_STRIP, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - debugUniformValues(posMatrix, Color.red), id, - painter.debugBuffer, painter.tileBorderIndexBuffer, painter.debugSegments); - const tileRawData = sourceCache.getTileByID(coord.key).latestRawTileData; const tileByteLength = (tileRawData && tileRawData.byteLength) || 0; const tileSizeKb = Math.floor(tileByteLength / 1024); @@ -99,8 +96,11 @@ function drawDebugTile(painter, sourceCache, coord: OverscaledTileID) { drawTextToOverlay(painter, tileLabel); program.draw(context, gl.TRIANGLES, depthMode, stencilMode, ColorMode.alphaBlended, CullFaceMode.disabled, - debugUniformValues(posMatrix, Color.transparent, scaleRatio), id, + debugUniformValues(posMatrix, Color.transparent, scaleRatio), null, id, painter.debugBuffer, painter.quadTriangleIndexBuffer, painter.debugSegments); + program.draw(context, gl.LINE_STRIP, depthMode, stencilMode, colorMode, CullFaceMode.disabled, + debugUniformValues(posMatrix, Color.red), terrainData, id, + painter.debugBuffer, painter.tileBorderIndexBuffer, painter.debugSegments); } function drawTextToOverlay(painter: Painter, text: string) { diff --git a/src/render/draw_fill.ts b/src/render/draw_fill.ts index 8f0af9f91db..e8a58733680 100644 --- a/src/render/draw_fill.ts +++ b/src/render/draw_fill.ts @@ -81,6 +81,7 @@ function drawFillTiles(painter, sourceCache, layer, coords, depthMode, colorMode const programConfiguration = bucket.programConfigurations.get(layer.id); const program = painter.useProgram(programName, programConfiguration); + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(coord); if (image) { painter.context.activeTexture.set(gl.TEXTURE0); @@ -96,7 +97,9 @@ function drawFillTiles(painter, sourceCache, layer, coords, depthMode, colorMode if (posTo && posFrom) programConfiguration.setConstantPatternPositions(posTo, posFrom); } - const tileMatrix = painter.translatePosMatrix(coord.posMatrix, tile, + 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')); if (!isOutline) { @@ -115,7 +118,7 @@ function drawFillTiles(painter, sourceCache, layer, coords, depthMode, colorMode } program.draw(painter.context, drawMode, depthMode, - painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, + painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, terrainData, 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 4080ae94f96..4eadce90026 100644 --- a/src/render/draw_fill_extrusion.ts +++ b/src/render/draw_fill_extrusion.ts @@ -58,6 +58,7 @@ function drawExtrusionTiles(painter, source, layer, coords, depthMode, stencilMo const bucket: FillExtrusionBucket = (tile.getBucket(layer) as any); if (!bucket) continue; + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(coord); const programConfiguration = bucket.programConfigurations.get(layer.id); const program = painter.useProgram(image ? 'fillExtrusionPattern' : 'fillExtrusion', programConfiguration); @@ -86,8 +87,8 @@ function drawExtrusionTiles(painter, source, layer, coords, depthMode, stencilMo fillExtrusionUniformValues(matrix, painter, shouldUseVerticalGradient, opacity); program.draw(context, context.gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.backCCW, - uniformValues, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, + uniformValues, terrainData, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, bucket.segments, layer.paint, painter.transform.zoom, - programConfiguration); + programConfiguration, painter.style.terrain && bucket.centroidVertexBuffer); } } diff --git a/src/render/draw_heatmap.ts b/src/render/draw_heatmap.ts index 1684480a402..108d38f01be 100644 --- a/src/render/draw_heatmap.ts +++ b/src/render/draw_heatmap.ts @@ -53,8 +53,7 @@ function drawHeatmap(painter: Painter, sourceCache: SourceCache, layer: HeatmapS 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')), + heatmapUniformValues(coord.posMatrix, tile, zoom, layer.paint.get('heatmap-intensity')), null, layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer, bucket.segments, layer.paint, painter.transform.zoom, programConfiguration); @@ -125,7 +124,7 @@ function renderTextureToMap(painter, layer) { painter.useProgram('heatmapTexture').draw(context, gl.TRIANGLES, DepthMode.disabled, StencilMode.disabled, painter.colorModeForRenderPass(), CullFaceMode.disabled, - heatmapTextureUniformValues(painter, layer, 0, 1), + heatmapTextureUniformValues(painter, layer, 0, 1), null, layer.id, painter.viewportBuffer, painter.quadTriangleIndexBuffer, painter.viewportSegments, layer.paint, painter.transform.zoom); } diff --git a/src/render/draw_hillshade.ts b/src/render/draw_hillshade.ts index 32443c17666..e5582ea17b0 100644 --- a/src/render/draw_hillshade.ts +++ b/src/render/draw_hillshade.ts @@ -27,32 +27,33 @@ function drawHillshade(painter: Painter, sourceCache: SourceCache, layer: Hillsh for (const coord of coords) { const tile = sourceCache.getTile(coord); - if (tile.needsHillshadePrepare && painter.renderPass === 'offscreen') { + 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, tile, layer, depthMode, stencilModes[coord.overscaledZ], colorMode); + renderHillshade(painter, coord, tile, layer, depthMode, stencilModes[coord.overscaledZ], colorMode); } } context.viewport.set([0, 0, painter.width, painter.height]); } -function renderHillshade(painter, tile, layer, depthMode, stencilMode, colorMode) { +function renderHillshade(painter, coord, tile, layer, depthMode, stencilMode, colorMode) { const context = painter.context; const gl = context.gl; const fbo = tile.fbo; if (!fbo) return; const program = painter.useProgram('hillshade'); + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(coord); context.activeTexture.set(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, fbo.colorAttachment.get()); - const uniformValues = hillshadeUniformValues(painter, tile, layer); - + const terrainCoord = terrainData ? coord : null; program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, layer.id, painter.rasterBoundsBuffer, + hillshadeUniformValues(painter, tile, layer, terrainCoord), terrainData, layer.id, painter.rasterBoundsBuffer, painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); + } // hillshade rendering is done in two steps. the prepare step first calculates the slope of the terrain in the x and y @@ -97,7 +98,7 @@ function prepareHillshade(painter, tile, layer, depthMode, stencilMode, colorMod painter.useProgram('hillshadePrepare').draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, hillshadeUniformPrepareValues(tile.tileID, dem), - layer.id, painter.rasterBoundsBuffer, + 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 1a3b95e058d..8f6a92672af 100644 --- a/src/render/draw_line.ts +++ b/src/render/draw_line.ts @@ -56,6 +56,7 @@ export default function drawLine(painter: Painter, sourceCache: SourceCache, lay const prevProgram = painter.context.program.get(); const program = painter.useProgram(programId, programConfiguration); const programChanged = firstTile || program.program !== prevProgram; + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(coord); const constantPattern = patternProperty.constantOr(null); if (constantPattern && tile.imageAtlas) { @@ -65,10 +66,11 @@ export default function drawLine(painter: Painter, sourceCache: SourceCache, lay if (posTo && posFrom) programConfiguration.setConstantPatternPositions(posTo, posFrom); } - const uniformValues = image ? linePatternUniformValues(painter, tile, layer, crossfade) : - dasharray ? lineSDFUniformValues(painter, tile, layer, dasharray, crossfade) : - gradient ? lineGradientUniformValues(painter, tile, layer, bucket.lineClipsArray.length) : - lineUniformValues(painter, tile, layer); + 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); if (image) { context.activeTexture.set(gl.TEXTURE0); @@ -113,7 +115,7 @@ export default function drawLine(painter: Painter, sourceCache: SourceCache, lay } program.draw(context, gl.TRIANGLES, depthMode, - painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, + painter.stencilModeForClipping(coord), colorMode, CullFaceMode.disabled, uniformValues, terrainData, 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 47e6689b8fe..0e881ce8f05 100644 --- a/src/render/draw_raster.ts +++ b/src/render/draw_raster.ts @@ -39,12 +39,11 @@ function drawRaster(painter: Painter, sourceCache: SourceCache, layer: RasterSty layer.paint.get('raster-opacity') === 1 ? DepthMode.ReadWrite : DepthMode.ReadOnly, gl.LESS); const tile = sourceCache.getTile(coord); - const posMatrix = painter.transform.calculatePosMatrix(coord.toUnwrapped(), align); tile.registerFadeDuration(layer.paint.get('raster-fade-duration')); const parentTile = sourceCache.findLoadedParent(coord, 0), - fade = getFadeValues(tile, parentTile, sourceCache, layer, painter.transform); + fade = getFadeValues(tile, parentTile, sourceCache, layer, painter.transform, painter.style.terrain); let parentScaleBy, parentTL; @@ -64,24 +63,27 @@ function drawRaster(painter: Painter, sourceCache: SourceCache, layer: RasterSty tile.texture.bind(textureFilter, gl.CLAMP_TO_EDGE, gl.LINEAR_MIPMAP_NEAREST); } + const terrainData = painter.style.terrain && painter.style.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, layer.id, source.boundsBuffer, + uniformValues, terrainData, layer.id, source.boundsBuffer, painter.quadTriangleIndexBuffer, source.boundsSegments); } else { program.draw(context, gl.TRIANGLES, depthMode, stencilModes[coord.overscaledZ], colorMode, CullFaceMode.disabled, - uniformValues, layer.id, painter.rasterBoundsBuffer, + uniformValues, terrainData, layer.id, painter.rasterBoundsBuffer, painter.quadTriangleIndexBuffer, painter.rasterBoundsSegments); } } } -function getFadeValues(tile, parentTile, sourceCache, layer, transform) { +function getFadeValues(tile, parentTile, sourceCache, layer, transform, terrain) { const fadeDuration = layer.paint.get('raster-fade-duration'); - if (fadeDuration > 0) { + if (!terrain && fadeDuration > 0) { const now = browser.now(); const sinceTile = (now - tile.timeAdded) / fadeDuration; const sinceParent = parentTile ? (now - parentTile.timeAdded) / fadeDuration : -1; diff --git a/src/render/draw_symbol.test.ts b/src/render/draw_symbol.test.ts index 2f6e4222e64..71970f79074 100644 --- a/src/render/draw_symbol.test.ts +++ b/src/render/draw_symbol.test.ts @@ -10,9 +10,13 @@ import drawSymbol from './draw_symbol'; import * as symbolProjection from '../symbol/projection'; import type ZoomHistory from '../style/zoom_history'; import type Map from '../ui/map'; -import type Transform from '../geo/transform'; +import Transform from '../geo/transform'; import type EvaluationParameters from '../style/evaluation_parameters'; import type {SymbolLayerSpecification} from '../style-spec/types.g'; +import Style from '../style/style'; +import TerrainSourceCache from '../source/terrain_source_cache'; +import {Evented} from '../util/evented'; +import {RequestManager} from '../util/request_manager'; jest.mock('./painter'); jest.mock('./program'); @@ -21,6 +25,22 @@ jest.mock('../source/tile'); jest.mock('../data/bucket/symbol_bucket'); jest.mock('../symbol/projection'); +class StubMap extends Evented { + transform: Transform; + painter: Painter; + _requestManager: RequestManager; + + constructor() { + super(); + this.transform = new Transform(); + this._requestManager = { + transformRequest: (url) => { + return {url}; + } + } as any as RequestManager; + } +} + describe('drawSymbol', () => { test('should not do anything', () => { const mockPainter = new Painter(null, null); @@ -37,12 +57,13 @@ describe('drawSymbol', () => { painterMock.context = { gl: {}, activeTexture: { - set: () => {} + set: () => { } } } as any; painterMock.renderPass = 'translucent'; painterMock.transform = {pitch: 0, labelPlaneMatrix: mat4.create()} as any as Transform; painterMock.options = {} as any; + painterMock.style = {terrainSourceCache: {getTerrain: () => null}} as any as Style; const layerSpec = { id: 'mock-layer', @@ -58,12 +79,12 @@ describe('drawSymbol', () => { const tileId = new OverscaledTileID(1, 0, 1, 0, 0); tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + const programMock = new Program(null, null, null, null, null, null, null); (painterMock.useProgram as jest.Mock).mockReturnValue(programMock); const bucketMock = new SymbolBucket(null); bucketMock.icon = { programConfigurations: { - get: () => {} + get: () => { } }, segments: { get: () => [1] @@ -76,7 +97,7 @@ describe('drawSymbol', () => { const tile = new Tile(tileId, 256); tile.tileID = tileId; tile.imageAtlasTexture = { - bind: () => {} + bind: () => { } } as any; (tile.getBucket as jest.Mock).mockReturnValue(bucketMock); const sourceCacheMock = new SourceCache(null, null, null); @@ -94,7 +115,7 @@ describe('drawSymbol', () => { painterMock.context = { gl: {}, activeTexture: { - set: () => {} + set: () => { } } } as any; painterMock.renderPass = 'translucent'; @@ -119,12 +140,12 @@ describe('drawSymbol', () => { const tileId = new OverscaledTileID(1, 0, 1, 0, 0); tileId.posMatrix = mat4.create(); - const programMock = new Program(null, null, null, null, null, null); + const programMock = new Program(null, null, null, null, null, null, null); (painterMock.useProgram as jest.Mock).mockReturnValue(programMock); const bucketMock = new SymbolBucket(null); bucketMock.icon = { programConfigurations: { - get: () => {} + get: () => { } }, segments: { get: () => [1] @@ -137,16 +158,19 @@ describe('drawSymbol', () => { const tile = new Tile(tileId, 256); tile.tileID = tileId; tile.imageAtlasTexture = { - bind: () => {} + 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; + painterMock.style = { + terrainSourceCache: new TerrainSourceCache(new Style(new StubMap() as any as Map)) + } as any as Style; const spy = jest.spyOn(symbolProjection, 'updateLineLabels'); drawSymbol(painterMock, sourceCacheMock, layer, [tileId], null); - expect(spy.mock.calls[0][8]).toBeFalsy(); // rotateToLine === false + expect(spy.mock.calls[0][9]).toBeFalsy(); // rotateToLine === false }); }); diff --git a/src/render/draw_symbol.ts b/src/render/draw_symbol.ts index b58ce6b7424..bdde9b1594b 100644 --- a/src/render/draw_symbol.ts +++ b/src/render/draw_symbol.ts @@ -9,7 +9,7 @@ import {mat4} from 'gl-matrix'; import StencilMode from '../gl/stencil_mode'; import DepthMode from '../gl/depth_mode'; import CullFaceMode from '../gl/cull_face_mode'; -import SymbolBucket, {addDynamicAttributes, SymbolBuffers} from '../data/bucket/symbol_bucket'; +import {addDynamicAttributes} from '../data/bucket/symbol_bucket'; import {getAnchorAlignment, WritingMode} from '../symbol/shaping'; import ONE_EM from '../symbol/one_em'; @@ -31,6 +31,9 @@ import type {OverscaledTileID} from '../source/tile_id'; import type {UniformValues} from './uniform_binding'; import type {SymbolSDFUniformsType} from '../render/program/symbol_program'; import type {CrossTileID, VariableOffset} from '../symbol/placement'; +import type SymbolBucket from '../data/bucket/symbol_bucket'; +import type {SymbolBuffers} from '../data/bucket/symbol_bucket'; +import type {TerrainData} from '../render/terrain'; import type {SymbolLayerSpecification} from '../style-spec/types.g'; import type Transform from '../geo/transform'; import type ColorMode from '../gl/color_mode'; @@ -39,6 +42,7 @@ import type Program from './program'; type SymbolTileRenderState = { segments: SegmentVector; sortKey: number; + terrainData: TerrainData; state: { program: Program; buffers: SymbolBuffers; @@ -145,8 +149,9 @@ function updateVariableAnchors(coords: Array, if (size) { const tileScale = Math.pow(2, tr.zoom - tile.tileID.overscaledZ); + const getElevation = painter.style.terrain ? (x: number, y: number) => painter.style.terrain.getElevation(coord, x, y) : null; updateVariableAnchorsForBucket(bucket, rotateWithMap, pitchWithMap, variableOffsets, - tr, labelPlaneMatrix, coord.posMatrix, tileScale, size, updateTextFitIcon); + tr, labelPlaneMatrix, coord.posMatrix, tileScale, size, updateTextFitIcon, getElevation); } } } @@ -161,7 +166,8 @@ function updateVariableAnchorsForBucket( posMatrix: mat4, tileScale: number, size: EvaluatedZoomSize, - updateTextFitIcon: boolean) { + updateTextFitIcon: boolean, + getElevation: (x: number, y: number) => number) { const placedSymbols = bucket.text.placedSymbolArray; const dynamicTextLayoutVertexArray = bucket.text.dynamicLayoutVertexArray; const dynamicIconLayoutVertexArray = bucket.icon.dynamicLayoutVertexArray; @@ -179,7 +185,7 @@ function updateVariableAnchorsForBucket( symbolProjection.hideGlyphs(symbol.numGlyphs, dynamicTextLayoutVertexArray); } else { const tileAnchor = new Point(symbol.anchorX, symbol.anchorY); - const projectedAnchor = symbolProjection.project(tileAnchor, pitchWithMap ? posMatrix : labelPlaneMatrix); + const projectedAnchor = symbolProjection.project(tileAnchor, pitchWithMap ? posMatrix : labelPlaneMatrix, getElevation); const perspectiveRatio = symbolProjection.getPerspectiveRatio(transform.cameraToCenterDistance, projectedAnchor.signedDistanceFromCamera); let renderTextSize = evaluateSizeForFeature(bucket.textSizeData, size, symbol) * perspectiveRatio / ONE_EM; if (pitchWithMap) { @@ -196,7 +202,7 @@ function updateVariableAnchorsForBucket( // calculated above. 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. const shiftedAnchor = pitchWithMap ? - symbolProjection.project(tileAnchor.add(shift), labelPlaneMatrix).point : + symbolProjection.project(tileAnchor.add(shift), labelPlaneMatrix, getElevation).point : projectedAnchor.point.add(rotateWithMap ? shift.rotate(-transform.angle) : shift); @@ -295,6 +301,7 @@ function drawLayerSymbols( const program = painter.useProgram(getSymbolProgramName(isSDF, isText, bucket), programConfiguration); const size = evaluateSizeForZoom(sizeData, tr.zoom); + const terrainData = painter.style.terrain && painter.style.terrain.getTerrainData(coord); let texSize: [number, number]; let texSizeIcon: [number, number] = [0, 0]; @@ -331,8 +338,9 @@ function drawLayerSymbols( bucket.hasIconData(); if (alongLine) { + const getElevation = painter.style.terrain ? (x: number, y: number) => painter.style.terrain.getElevation(coord, x, y) : null; const rotateToLine = layer.layout.get('text-rotation-alignment') === 'map'; - symbolProjection.updateLineLabels(bucket, coord.posMatrix, painter, isText, labelPlaneMatrix, glCoordMatrix, pitchWithMap, keepUpright, rotateToLine); + symbolProjection.updateLineLabels(bucket, coord.posMatrix, painter, isText, labelPlaneMatrix, glCoordMatrix, pitchWithMap, keepUpright, rotateToLine, getElevation); } const matrix = painter.translatePosMatrix(coord.posMatrix, tile, translate, translateAnchor), @@ -377,14 +385,16 @@ function drawLayerSymbols( tileRenderState.push({ segments: new SegmentVector([segment]), sortKey: segment.sortKey, - state + state, + terrainData }); } } else { tileRenderState.push({ segments: buffers.segments, sortKey: 0, - state + state, + terrainData }); } } @@ -409,11 +419,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); + drawSymbolElements(state.buffers, segmentState.segments, layer, painter, state.program, depthMode, stencilMode, colorMode, uniformValues, segmentState.terrainData); } uniformValues['u_is_halo'] = 0; } - drawSymbolElements(state.buffers, segmentState.segments, layer, painter, state.program, depthMode, stencilMode, colorMode, state.uniformValues); + drawSymbolElements(state.buffers, segmentState.segments, layer, painter, state.program, depthMode, stencilMode, colorMode, state.uniformValues, segmentState.terrainData); } } @@ -426,11 +436,12 @@ function drawSymbolElements( depthMode: Readonly, stencilMode: StencilMode, colorMode: Readonly, - uniformValues: UniformValues) { + uniformValues: UniformValues, + terrainData: TerrainData) { const context = painter.context; const gl = context.gl; program.draw(context, gl.TRIANGLES, depthMode, stencilMode, colorMode, CullFaceMode.disabled, - uniformValues, layer.id, buffers.layoutVertexBuffer, + uniformValues, terrainData, 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 new file mode 100644 index 00000000000..7ee2abb0cf6 --- /dev/null +++ b/src/render/draw_terrain.ts @@ -0,0 +1,123 @@ +import StencilMode from '../gl/stencil_mode'; +import DepthMode from '../gl/depth_mode'; +import {terrainUniformValues, terrainDepthUniformValues, terrainCoordsUniformValues} from './program/terrain_program'; +import type Painter from './painter'; +import type Tile from '../source/tile'; +import CullFaceMode from '../gl/cull_face_mode'; +import Texture from './texture'; +import Color from '../style-spec/util/color'; +import ColorMode from '../gl/color_mode'; +import Terrain from './terrain'; + +/** + * Redraw the Depth Framebuffer + * @param {Painter} painter - the painter + * @param {Terrain} terrain - the terrain + */ +function drawDepth(painter: Painter, terrain: Terrain) { + const context = painter.context; + const gl = context.gl; + const colorMode = ColorMode.unblended; + const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, [0, 1]); + const mesh = terrain.getTerrainMesh(); + const tiles = terrain.sourceCache.getRenderableTiles(); + const program = painter.useProgram('terrainDepth'); + context.bindFramebuffer.set(terrain.getFramebuffer('depth').framebuffer); + context.viewport.set([0, 0, painter.width / devicePixelRatio, painter.height / devicePixelRatio]); + 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); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + } + context.bindFramebuffer.set(null); + context.viewport.set([0, 0, painter.width, painter.height]); +} + +/** + * Redraw the Coords Framebuffers + * @param {Painter} painter - the painter + * @param {Terrain} terrain - the terrain + */ +function drawCoords(painter: Painter, terrain: Terrain) { + const context = painter.context; + const gl = context.gl; + const colorMode = ColorMode.unblended; + const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, [0, 1]); + const mesh = terrain.getTerrainMesh(); + const coords = terrain.getCoordsTexture(); + const tiles = terrain.sourceCache.getRenderableTiles(); + + // draw tile-coords into framebuffer + const program = painter.useProgram('terrainCoords'); + context.bindFramebuffer.set(terrain.getFramebuffer('coords').framebuffer); + context.viewport.set([0, 0, painter.width / devicePixelRatio, painter.height / devicePixelRatio]); + context.clear({color: Color.transparent, depth: 1}); + terrain.coordsIndex = []; + for (const tile of tiles) { + 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); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); + terrain.coordsIndex.push(tile.tileID.key); + } + + context.bindFramebuffer.set(null); + context.viewport.set([0, 0, painter.width, painter.height]); +} + +/** + * Render, e.g. drape, a render-to-texture tile onto the 3d mesh on screen. + * @param {Painter} painter - the painter + * @param {Terrain} terrain - the source cache + * @param {Tile} tile - the tile + */ +function drawTerrain(painter: Painter, terrain: Terrain, tile: Tile) { + const context = painter.context; + const gl = context.gl; + const colorMode = painter.colorModeForRenderPass(); + const depthMode = new DepthMode(gl.LEQUAL, DepthMode.ReadWrite, painter.depthRangeFor3D); + const program = painter.useProgram('terrain'); + const mesh = terrain.getTerrainMesh(); + const terrainData = terrain.getTerrainData(tile.tileID); + + context.bindFramebuffer.set(null); + context.viewport.set([0, 0, painter.width, painter.height]); + context.activeTexture.set(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, terrain.getRTTFramebuffer().colorAttachment.get()); + const posMatrix = painter.transform.calculatePosMatrix(tile.tileID.toUnwrapped()); + const uniformValues = terrainUniformValues(posMatrix); + program.draw(context, gl.TRIANGLES, depthMode, StencilMode.disabled, colorMode, CullFaceMode.backCCW, uniformValues, terrainData, 'terrain', mesh.vertexBuffer, mesh.indexBuffer, mesh.segments); +} + +/** + * prepare the render-to-texture tile. + * E.g. creates the necessary textures and attach them to the render-to-texture-framebuffer. + * @param {Painter} painter - the painter + * @param {Terrain} terrain - the terrain + * @param {Tile} tile - the tile + * @param {number} stack number of a layer-groop. see painter.ts + */ +function prepareTerrain(painter: Painter, terrain: Terrain, tile: Tile, stack: number) { + const context = painter.context; + const size = tile.tileSize * terrain.qualityFactor; + if (!tile.textures[stack]) { + tile.textures[stack] = painter.getTileTexture(size) || new Texture(context, {width: size, height: size, data: null}, context.gl.RGBA); + tile.textures[stack].bind(context.gl.LINEAR, context.gl.CLAMP_TO_EDGE); + if (stack === 0) terrain.sourceCache.renderHistory.push(tile.tileID.key); + } + const fb = terrain.getRTTFramebuffer(); + fb.colorAttachment.set(tile.textures[stack].texture); + context.bindFramebuffer.set(fb.framebuffer); + context.viewport.set([0, 0, size, size]); +} + +export { + prepareTerrain, + drawTerrain, + drawDepth, + drawCoords +}; diff --git a/src/render/painter.ts b/src/render/painter.ts index e2837c4d3af..3feba23690a 100644 --- a/src/render/painter.ts +++ b/src/render/painter.ts @@ -1,5 +1,4 @@ import browser from '../util/browser'; - import {mat4, vec3} from 'gl-matrix'; import SourceCache from '../source/source_cache'; import EXTENT from '../data/extent'; @@ -32,6 +31,8 @@ import raster from './draw_raster'; import background from './draw_background'; import debug, {drawDebugPadding} from './draw_debug'; import custom from './draw_custom'; +import {drawDepth, drawCoords} from './draw_terrain'; +import {OverscaledTileID} from '../source/tile_id'; const draw = { symbol, @@ -49,7 +50,6 @@ const draw = { import type Transform from '../geo/transform'; import type Tile from '../source/tile'; -import type {OverscaledTileID} from '../source/tile_id'; import type Style from '../style/style'; import type StyleLayer from '../style/style_layer'; import type {CrossFaded} from '../style/properties'; @@ -61,6 +61,7 @@ import type IndexBuffer from '../gl/index_buffer'; import type {DepthRangeType, DepthMaskType, DepthFuncType} from '../gl/types'; import type ResolvedImage from '../style-spec/expression/types/resolved_image'; import type {RGBAImage} from '../util/image'; +import RenderToTexture from './render_to_texture'; export type RenderPass = 'offscreen' | 'opaque' | 'translucent'; @@ -125,11 +126,16 @@ class Painter { emptyTexture: Texture; debugOverlayTexture: Texture; debugOverlayCanvas: HTMLCanvasElement; + // this object stores the current camera-matrix and the last render time + // of the terrain-facilitators. e.g. depth & coords framebuffers + // every time the camera-matrix changes the terrain-facilitators will be redrawn. + terrainFacilitator: {dirty: boolean; matrix: mat4; renderTime: number}; constructor(gl: WebGLRenderingContext, transform: Transform) { this.context = new Context(gl); this.transform = transform; this._tileTextures = {}; + this.terrainFacilitator = {dirty: true, matrix: mat4.create(), renderTime: 0}; this.setup(); @@ -240,7 +246,7 @@ class Painter { this.useProgram('clippingMask').draw(context, gl.TRIANGLES, DepthMode.disabled, this.stencilClearMode, ColorMode.disabled, CullFaceMode.disabled, - clippingMaskUniformValues(matrix), + clippingMaskUniformValues(matrix), null, '$clipping', this.viewportBuffer, this.quadTriangleIndexBuffer, this.viewportSegments); } @@ -267,12 +273,13 @@ class Painter { for (const tileID of tileIDs) { const id = this._tileClippingMaskIDs[tileID.key] = this.nextStencilID++; + const terrainData = this.style.terrain && this.style.terrain.getTerrainData(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), - '$clipping', this.tileExtentBuffer, + terrainData, '$clipping', this.tileExtentBuffer, this.quadTriangleIndexBuffer, this.tileExtentSegments); } } @@ -371,6 +378,7 @@ class Painter { const layerIds = this.style._order; const sourceCaches = this.style.sourceCaches; + const renderToTexture = this.style.terrain && new RenderToTexture(this); for (const id in sourceCaches) { const sourceCache = sourceCaches[id]; @@ -399,6 +407,21 @@ class Painter { } } + if (renderToTexture) { + // this is disabled, because render-to-texture is rendering all layers from bottom to top. + this.opaquePassCutoff = 0; + + // update coords/depth-framebuffer on camera movement, or tile reloading + const newTiles = this.style.terrain.sourceCache.tilesAfterTime(this.terrainFacilitator.renderTime); + if (this.terrainFacilitator.dirty || !mat4.equals(this.terrainFacilitator.matrix, this.transform.projMatrix) || newTiles.length) { + mat4.copy(this.terrainFacilitator.matrix, this.transform.projMatrix); + this.terrainFacilitator.renderTime = Date.now(); + this.terrainFacilitator.dirty = false; + drawDepth(this, this.style.terrain); + drawCoords(this, this.style.terrain); + } + } + // Offscreen pass =============================================== // We first do all rendering that requires rendering to a separate // framebuffer, and then save those for rendering back to the map @@ -427,15 +450,17 @@ class Painter { // Opaque pass =============================================== // Draw opaque layers top-to-bottom first. - this.renderPass = 'opaque'; + if (!renderToTexture) { + this.renderPass = 'opaque'; - for (this.currentLayer = layerIds.length - 1; this.currentLayer >= 0; this.currentLayer--) { - const layer = this.style._layers[layerIds[this.currentLayer]]; - const sourceCache = sourceCaches[layer.source]; - const coords = coordsAscending[layer.source]; + for (this.currentLayer = layerIds.length - 1; this.currentLayer >= 0; this.currentLayer--) { + const layer = this.style._layers[layerIds[this.currentLayer]]; + const sourceCache = sourceCaches[layer.source]; + const coords = coordsAscending[layer.source]; - this._renderTileClippingMasks(layer, coords); - this.renderLayer(this, sourceCache, layer, coords); + this._renderTileClippingMasks(layer, coords); + this.renderLayer(this, sourceCache, layer, coords); + } } // Translucent pass =============================================== @@ -446,6 +471,8 @@ class Painter { const layer = this.style._layers[layerIds[this.currentLayer]]; const sourceCache = sourceCaches[layer.source]; + if (renderToTexture && renderToTexture.renderLayer(layer)) continue; + // For symbol layers in the translucent pass, we add extra tiles to the renderable set // for cross-tile symbol fading. Symbol layers don't use tile clipping, so no need to render // separate clipping masks @@ -486,7 +513,7 @@ class Painter { renderLayer(painter: Painter, sourceCache: SourceCache, layer: StyleLayer, coords: Array) { if (layer.isHidden(this.transform.zoom)) return; - if (layer.type !== 'background' && layer.type !== 'custom' && !coords.length) return; + if (layer.type !== 'background' && layer.type !== 'custom' && !(coords || []).length) return; this.id = layer.id; this.gpuTimingStart(layer); @@ -600,9 +627,20 @@ class Painter { useProgram(name: string, programConfiguration?: ProgramConfiguration | null): Program { this.cache = this.cache || {}; - const key = `${name}${programConfiguration ? programConfiguration.cacheKey : ''}${this._showOverdrawInspector ? '/overdraw' : ''}`; + const key = name + + (programConfiguration ? programConfiguration.cacheKey : '') + + (this._showOverdrawInspector ? '/overdraw' : '') + + (this.style.terrain ? '/terrain' : ''); if (!this.cache[key]) { - this.cache[key] = new Program(this.context, name, shaders[name], programConfiguration, programUniforms[name], this._showOverdrawInspector); + this.cache[key] = new Program( + this.context, + name, + shaders[name], + programConfiguration, + programUniforms[name], + this._showOverdrawInspector, + this.style.terrain + ); } return this.cache[key]; } diff --git a/src/render/program.ts b/src/render/program.ts index f42aa2920f6..5427b4286aa 100644 --- a/src/render/program.ts +++ b/src/render/program.ts @@ -13,6 +13,9 @@ import type ColorMode from '../gl/color_mode'; import type CullFaceMode from '../gl/cull_face_mode'; import type {UniformBindings, UniformValues, UniformLocations} from './uniform_binding'; 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'; export type DrawMode = WebGLRenderingContext['LINES'] | WebGLRenderingContext['TRIANGLES'] | WebGLRenderingContext['LINE_STRIP']; @@ -31,6 +34,7 @@ class Program { attributes: {[_: string]: number}; numAttributes: number; fixedUniforms: Us; + terrainUniforms: TerrainPreludeUniformsType; binderUniforms: Array; failedToCreate: boolean; @@ -44,7 +48,9 @@ class Program { }, configuration: ProgramConfiguration, fixedUniforms: (b: Context, a: UniformLocations) => Us, - showOverdrawInspector: boolean) { + showOverdrawInspector: boolean, + terrain: Terrain) { + const gl = context.gl; this.program = gl.createProgram(); @@ -52,10 +58,11 @@ class Program { const dynamicAttrInfo = configuration ? configuration.getBinderAttributes() : []; const allAttrInfo = staticAttrInfo.concat(dynamicAttrInfo); + const preludeUniformsInfo = shaders.prelude.staticUniforms ? getTokenizedAttributesAndUniforms(shaders.prelude.staticUniforms) : []; const staticUniformsInfo = source.staticUniforms ? getTokenizedAttributesAndUniforms(source.staticUniforms) : []; const dynamicUniformsInfo = configuration ? configuration.getBinderUniforms() : []; // remove duplicate uniforms - const uniformList = staticUniformsInfo.concat(dynamicUniformsInfo); + const uniformList = preludeUniformsInfo.concat(staticUniformsInfo).concat(dynamicUniformsInfo); const allUniformsInfo = []; for (const uniform of uniformList) { if (allUniformsInfo.indexOf(uniform) < 0) allUniformsInfo.push(uniform); @@ -65,7 +72,9 @@ class Program { if (showOverdrawInspector) { defines.push('#define OVERDRAW_INSPECTOR;'); } - + if (terrain) { + defines.push('#define TERRAIN3D;'); + } const fragmentSource = defines.concat(shaders.prelude.fragmentSource, source.fragmentSource).join('\n'); const vertexSource = defines.concat(shaders.prelude.vertexSource, source.vertexSource).join('\n'); const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); @@ -117,6 +126,7 @@ class Program { } this.fixedUniforms = fixedUniforms(context, uniformLocations); + this.terrainUniforms = terrainPreludeUniforms(context, uniformLocations); this.binderUniforms = configuration ? configuration.getUniforms(context, uniformLocations) : []; } @@ -127,6 +137,7 @@ class Program { colorMode: Readonly, cullFaceMode: Readonly, uniformValues: UniformValues, + terrain: TerrainData, layerID: string, layoutVertexBuffer: VertexBuffer, indexBuffer: IndexBuffer, @@ -135,7 +146,8 @@ class Program { zoom?: number | null, configuration?: ProgramConfiguration | null, dynamicLayoutBuffer?: VertexBuffer | null, - dynamicLayoutBuffer2?: VertexBuffer | null) { + dynamicLayoutBuffer2?: VertexBuffer | null, + dynamicLayoutBuffer3?: VertexBuffer | null) { const gl = context.gl; @@ -147,6 +159,17 @@ class Program { context.setColorMode(colorMode); context.setCullFace(cullFaceMode); + // set varaibles used by the 3d functions defined in _prelude.vertex.glsl + if (terrain) { + context.activeTexture.set(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, terrain.depthTexture); + context.activeTexture.set(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, terrain.texture); + for (const name in this.terrainUniforms) { + this.terrainUniforms[name].set(terrain[name]); + } + } + for (const name in this.fixedUniforms) { this.fixedUniforms[name].set(uniformValues[name]); } @@ -173,7 +196,8 @@ class Program { indexBuffer, segment.vertexOffset, dynamicLayoutBuffer, - dynamicLayoutBuffer2 + dynamicLayoutBuffer2, + dynamicLayoutBuffer3 ); gl.drawElements( diff --git a/src/render/program/hillshade_program.ts b/src/render/program/hillshade_program.ts index 5d09504e30c..81a29165d09 100644 --- a/src/render/program/hillshade_program.ts +++ b/src/render/program/hillshade_program.ts @@ -55,7 +55,12 @@ const hillshadePrepareUniforms = (context: Context, locations: UniformLocations) 'u_unpack': new Uniform4f(context, locations.u_unpack) }); -const hillshadeUniformValues = (painter: Painter, tile: Tile, layer: HillshadeStyleLayer): UniformValues => { +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'); const accent = layer.paint.get('hillshade-accent-color'); @@ -67,7 +72,7 @@ const hillshadeUniformValues = (painter: Painter, tile: Tile, layer: HillshadeSt } const align = !painter.options.moving; return { - 'u_matrix': painter.transform.calculatePosMatrix(tile.tileID.toUnwrapped(), align), + '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 89d6b5bdb2f..4c383077318 100644 --- a/src/render/program/line_program.ts +++ b/src/render/program/line_program.ts @@ -10,6 +10,7 @@ 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; @@ -93,11 +94,16 @@ const lineSDFUniforms = (context: Context, locations: UniformLocations): LineSDF 'u_mix': new Uniform1f(context, locations.u_mix) }); -const lineUniformValues = (painter: Painter, tile: Tile, layer: LineStyleLayer): UniformValues => { +const lineUniformValues = ( + painter: Painter, + tile: Tile, + layer: LineStyleLayer, + coord: OverscaledTileID +): UniformValues => { const transform = painter.transform; return { - 'u_matrix': calculateMatrix(painter, tile, layer), + 'u_matrix': calculateMatrix(painter, tile, layer, coord), 'u_ratio': 1 / pixelsToTileUnits(tile, 1, transform.zoom), 'u_device_pixel_ratio': painter.pixelRatio, 'u_units_to_pixels': [ @@ -107,8 +113,14 @@ const lineUniformValues = (painter: Painter, tile: Tile, layer: LineStyleLayer): }; }; -const lineGradientUniformValues = (painter: Painter, tile: Tile, layer: LineStyleLayer, imageHeight: number): UniformValues => { - return extend(lineUniformValues(painter, tile, layer), { +const lineGradientUniformValues = ( + painter: Painter, + tile: Tile, + layer: LineStyleLayer, + imageHeight: number, + coord: OverscaledTileID +): UniformValues => { + return extend(lineUniformValues(painter, tile, layer, coord), { 'u_image': 0, 'u_image_height': imageHeight, }); @@ -118,12 +130,13 @@ const linePatternUniformValues = ( painter: Painter, tile: Tile, layer: LineStyleLayer, - crossfade: CrossfadeParameters + crossfade: CrossfadeParameters, + coord: OverscaledTileID ): UniformValues => { const transform = painter.transform; const tileZoomRatio = calculateTileRatio(tile, transform); return { - 'u_matrix': calculateMatrix(painter, tile, layer), + 'u_matrix': calculateMatrix(painter, tile, layer, coord), 'u_texsize': tile.imageAtlasTexture.size, // camera zoom ratio 'u_ratio': 1 / pixelsToTileUnits(tile, 1, transform.zoom), @@ -143,7 +156,8 @@ const lineSDFUniformValues = ( tile: Tile, layer: LineStyleLayer, dasharray: CrossFaded>, - crossfade: CrossfadeParameters + crossfade: CrossfadeParameters, + coord: OverscaledTileID ): UniformValues => { const transform = painter.transform; const lineAtlas = painter.lineAtlas; @@ -157,7 +171,7 @@ const lineSDFUniformValues = ( const widthA = posA.width * crossfade.fromScale; const widthB = posB.width * crossfade.toScale; - return extend(lineUniformValues(painter, tile, layer), { + return extend(lineUniformValues(painter, tile, layer, coord), { '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, @@ -172,9 +186,9 @@ function calculateTileRatio(tile: Tile, transform: Transform) { return 1 / pixelsToTileUnits(tile, 1, transform.tileZoom); } -function calculateMatrix(painter, tile, layer) { +function calculateMatrix(painter, tile, layer, coord) { return painter.translatePosMatrix( - tile.tileID.posMatrix, + coord ? coord.posMatrix : tile.tileID.posMatrix, 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 25f2271d9f9..f1723ccac78 100644 --- a/src/render/program/program_uniforms.ts +++ b/src/render/program/program_uniforms.ts @@ -10,6 +10,7 @@ import {lineUniforms, lineGradientUniforms, linePatternUniforms, lineSDFUniforms 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'; export const programUniforms = { fillExtrusion: fillExtrusionUniforms, @@ -36,5 +37,8 @@ export const programUniforms = { symbolSDF: symbolSDFUniforms, symbolTextAndIcon: symbolTextAndIconUniforms, background: backgroundUniforms, - backgroundPattern: backgroundPatternUniforms + backgroundPattern: backgroundPatternUniforms, + terrain: terrainUniforms, + terrainDepth: terrainDepthUniforms, + terrainCoords: terrainCoordsUniforms }; diff --git a/src/render/program/terrain_program.ts b/src/render/program/terrain_program.ts new file mode 100644 index 00000000000..2f7890307eb --- /dev/null +++ b/src/render/program/terrain_program.ts @@ -0,0 +1,83 @@ +import { + Uniform1i, + Uniform1f, + Uniform4f, + UniformMatrix4f +} from '../uniform_binding'; +import type Context from '../../gl/context'; +import type {UniformValues, UniformLocations} from '../../render/uniform_binding'; +import {mat4} from 'gl-matrix'; + +export type TerrainPreludeUniformsType = { + 'u_depth': Uniform1i; + 'u_terrain': Uniform1i; + 'u_terrain_dim': Uniform1f; + 'u_terrain_matrix': UniformMatrix4f; + 'u_terrain_unpack': Uniform4f; + 'u_terrain_offset': Uniform1f; + 'u_terrain_exaggeration': Uniform1f; +}; + +export type TerrainUniformsType = { + 'u_matrix': UniformMatrix4f; + 'u_texture': Uniform1i; +}; + +export type TerrainDepthUniformsType = { + 'u_matrix': UniformMatrix4f; +}; + +export type TerrainCoordsUniformsType = { + 'u_matrix': UniformMatrix4f; + 'u_texture': Uniform1i; + 'u_terrain_coords_id': Uniform1f; +}; + +const terrainPreludeUniforms = (context: Context, locations: UniformLocations): TerrainPreludeUniformsType => ({ + 'u_depth': new Uniform1i(context, locations.u_depth), + 'u_terrain': new Uniform1i(context, locations.u_terrain), + 'u_terrain_dim': new Uniform1f(context, locations.u_terrain_dim), + 'u_terrain_matrix': new UniformMatrix4f(context, locations.u_terrain_matrix), + 'u_terrain_unpack': new Uniform4f(context, locations.u_terrain_unpack), + 'u_terrain_offset': new Uniform1f(context, locations.u_terrain_offset), + 'u_terrain_exaggeration': new Uniform1f(context, locations.u_terrain_exaggeration) +}); + +const terrainUniforms = (context: Context, locations: UniformLocations): TerrainUniformsType => ({ + 'u_matrix': new UniformMatrix4f(context, locations.u_matrix), + 'u_texture': new Uniform1i(context, locations.u_texture) +}); + +const terrainDepthUniforms = (context: Context, locations: UniformLocations): TerrainDepthUniformsType => ({ + 'u_matrix': new UniformMatrix4f(context, locations.u_matrix) +}); + +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) +}); + +const terrainUniformValues = ( + matrix: mat4 +): UniformValues => ({ + 'u_matrix': matrix, + 'u_texture': 0 +}); + +const terrainDepthUniformValues = ( + matrix: mat4 +): UniformValues => ({ + 'u_matrix': matrix +}); + +const terrainCoordsUniformValues = ( + matrix: mat4, + coordsId: number +): UniformValues => ({ + 'u_matrix': matrix, + 'u_terrain_coords_id': coordsId / 255, + 'u_texture': 0 +}); + +export {terrainUniforms, terrainDepthUniforms, terrainCoordsUniforms, terrainPreludeUniforms, terrainUniformValues, terrainDepthUniformValues, terrainCoordsUniformValues}; diff --git a/src/render/render_to_texture.test.ts b/src/render/render_to_texture.test.ts new file mode 100644 index 00000000000..e4f5d1141c0 --- /dev/null +++ b/src/render/render_to_texture.test.ts @@ -0,0 +1,41 @@ +import RenderToTexture from './render_to_texture'; +import type Painter from './painter'; +import type LineStyleLayer from '../style/style_layer/line_style_layer'; +import type SymbolStyleLayer from '../style/style_layer/symbol_style_layer'; + +describe('render to texture', () => { + test('should render text after a line by not adding the text to the stack', () => { + const painterMock = { + style: { + terrain: { + sourceCache: { + getRenderableTiles: () => [], + removeOutdated: () => {} + }, + clearRerenderCache: () => {} + }, + _order: [] + } + } as any as Painter; + const uut = new RenderToTexture(painterMock); + const lineLayer = { + id: 'maine-line', + type: 'line', + source: 'maine' + } as LineStyleLayer; + const symbolLayer = { + id: 'maine-text', + type: 'symbol', + source: 'maine', + layout: { + 'text-field': 'maine', + 'symbol-placement': 'line' + } + } as any as SymbolStyleLayer; + + expect(uut.renderLayer(lineLayer)).toBeTruthy(); + painterMock.style._order = ['maine-line', 'maine-text']; + painterMock.currentLayer = 1; + expect(uut.renderLayer(symbolLayer)).toBeFalsy(); + }); +}); diff --git a/src/render/render_to_texture.ts b/src/render/render_to_texture.ts new file mode 100644 index 00000000000..b2ad0071590 --- /dev/null +++ b/src/render/render_to_texture.ts @@ -0,0 +1,154 @@ +import Painter from './painter'; +import Tile from '../source/tile'; +import Color from '../style-spec/util/color'; +import {OverscaledTileID} from '../source/tile_id'; +import {prepareTerrain, drawTerrain} from './draw_terrain'; +import type StyleLayer from '../style/style_layer'; + +export default class RenderToTexture { + painter: Painter; + // this object holds a lookup table which layers should rendered to texture + _renderToTexture: {[keyof in StyleLayer['type']]?: boolean}; + // coordsDescendingInv contains a list of all tiles which should be rendered for one render-to-texture tile + // e.g. render 4 raster-tiles with size 256px to the 512px render-to-texture tile + _coordsDescendingInv: {[_: string]: {[_:string]: Array}} = {}; + // create a string representation of all to tiles rendered to render-to-texture tiles + // this string representation is used to check if tile should be re-rendered. + _coordsDescendingInvStr: {[_: string]: {[_:string]: string}} = {}; + // store for render-stacks + // a render stack is a set of layers which should be rendered into one texture + // every stylesheet can have multipe stacks. A new stack is created if layers which should + // not rendered to texture sit inbetween layers which should rendered to texture. e.g. hillshading or symbols + _stacks: Array>; + // remember the previous processed layer to check if a new stack is needed + _prevType: string; + // create a lookup which tiles should rendered to texture + _rerender: {[_: string]: boolean}; + // a list of tiles that can potentially rendered + _renderableTiles: Array; + + constructor(painter: Painter) { + this.painter = painter; + this._renderToTexture = {background: true, fill: true, line: true, raster: true}; + this._coordsDescendingInv = {}; + this._coordsDescendingInvStr = {}; + this._stacks = []; + this._prevType = null; + this._rerender = {}; + this._renderableTiles = painter.style.terrain.sourceCache.getRenderableTiles(); + this._init(); + } + + _init() { + const style = this.painter.style; + const terrain = style.terrain; + + // fill _coordsDescendingInv + for (const id in style.sourceCaches) { + this._coordsDescendingInv[id] = {}; + const tileIDs = style.sourceCaches[id].getVisibleCoordinates(); + for (const tileID of tileIDs) { + const keys = terrain.sourceCache.getTerrainCoords(tileID); + for (const key in keys) { + if (!this._coordsDescendingInv[id][key]) this._coordsDescendingInv[id][key] = []; + this._coordsDescendingInv[id][key].push(keys[key]); + } + } + } + + // fill _coordsDescendingInvStr + for (const id of style._order) { + const layer = style._layers[id], source = layer.source; + if (this._renderToTexture[layer.type]) { + if (!this._coordsDescendingInvStr[source]) { + this._coordsDescendingInvStr[source] = {}; + for (const key in this._coordsDescendingInv[source]) + this._coordsDescendingInvStr[source][key] = this._coordsDescendingInv[source][key].map(c => c.key).sort().join(); + } + } + } + + // remove cached textures + this._renderableTiles.forEach(tile => { + for (const source in this._coordsDescendingInvStr) { + // rerender if there are more coords to render than in the last rendering + const coords = this._coordsDescendingInvStr[source][tile.tileID.key]; + if (coords && coords !== tile.textureCoords[source]) tile.clearTextures(this.painter); + // rerender if tile is marked for rerender + if (terrain.needsRerender(source, tile.tileID)) tile.clearTextures(this.painter); + } + this._rerender[tile.tileID.key] = !tile.textures.length; + }); + terrain.clearRerenderCache(); + terrain.sourceCache.removeOutdated(this.painter); + + return this; + } + + /** + * due that switching textures is relatively slow, the render + * layer-by-layer context is not practicable. To bypass this problem + * this lines of code stack all layers and later render all at once. + * Because of the stylesheet possibility to mixing render-to-texture layers + * and 'live'-layers (f.e. symbols) it is necessary to create more stacks. For example + * a symbol-layer is in between of fill-layers. + * @param {StyleLayer} layer the layer to render + * @returns {boolean} if true layer is rendered to texture, otherwise false + */ + renderLayer(layer: StyleLayer): boolean { + const type = layer.type; + const painter = this.painter; + const layerIds = painter.style._order; + const currentLayer = painter.currentLayer; + const isLastLayer = currentLayer + 1 === layerIds.length; + + // remember background, fill, line & raster layer to render into a stack + if (this._renderToTexture[type]) { + if (!this._prevType || !this._renderToTexture[this._prevType]) this._stacks.push([]); + this._prevType = type; + this._stacks[this._stacks.length - 1].push(layerIds[currentLayer]); + // rendering is done later, all in once + if (!isLastLayer) return true; + } + + // in case a stack is finished render all collected stack-layers into a texture + if (this._renderToTexture[this._prevType] || type === 'hillshade' || (this._renderToTexture[type] && isLastLayer)) { + this._prevType = type; + const stack = this._stacks.length - 1, layers = this._stacks[stack] || []; + for (const tile of this._renderableTiles) { + prepareTerrain(painter, painter.style.terrain, tile, stack); + if (this._rerender[tile.tileID.key]) { + painter.context.clear({color: Color.transparent}); + for (let l = 0; l < layers.length; l++) { + const layer = painter.style._layers[layers[l]]; + const coords = layer.source ? this._coordsDescendingInv[layer.source][tile.tileID.key] : [tile.tileID]; + painter._renderTileClippingMasks(layer, coords); + painter.renderLayer(painter, painter.style.sourceCaches[layer.source], layer, coords); + if (layer.source) tile.textureCoords[layer.source] = this._coordsDescendingInvStr[layer.source][tile.tileID.key]; + } + } + drawTerrain(painter, painter.style.terrain, tile); + } + + // the hillshading layer is a special case because it changes on every camera-movement + // so rerender it in any case. + if (type === 'hillshade') { + this._stacks.push([layerIds[currentLayer]]); + for (const tile of this._renderableTiles) { + const coords = this._coordsDescendingInv[layer.source][tile.tileID.key]; + prepareTerrain(painter, painter.style.terrain, tile, this._stacks.length - 1); + painter.context.clear({color: Color.transparent}); + painter._renderTileClippingMasks(layer, coords); + painter.renderLayer(painter, painter.style.sourceCaches[layer.source], layer, coords); + drawTerrain(painter, painter.style.terrain, tile); + } + return true; + } + + return this._renderToTexture[type]; + } + + return false; + } + +} diff --git a/src/render/terrain.test.ts b/src/render/terrain.test.ts new file mode 100644 index 00000000000..309c906219f --- /dev/null +++ b/src/render/terrain.test.ts @@ -0,0 +1,53 @@ +import Point from '@mapbox/point-geometry'; +import Terrain from './terrain'; +import gl from 'gl'; +import Context from '../gl/context'; +import {RGBAImage} from '../util/image'; +import Texture from './texture'; +import type Style from '../style/style'; +import type SourceCache from '../source/source_cache'; +import type TerrainSourceCache from '../source/terrain_source_cache'; +import type {TerrainSpecification} from '../style-spec/types.g'; + +describe('Terrain', () => { + test('pointCoordiate should not return null', () => { + const style = { + map: { + painter: { + context: new Context(gl(1, 1)), + width: 1, + height: 1 + } + } + } as any as Style; + const sourceCache = { + getTileByID: (tileID) => { + if (tileID !== 'abcd') { + return null; + } + return { + tileID: { + canonical: { + x: 0, + y: 0, + z: 0 + } + } + }; + } + } as any as TerrainSourceCache; + const terrain = new Terrain(style, {} as any as SourceCache, {} as any as TerrainSpecification); + terrain.sourceCache = sourceCache; + const context = style.map.painter.context as Context; + const pixels = new Uint8Array([0, 0, 255, 255]); + const image = new RGBAImage({width: 1, height: 1}, pixels); + const imageTexture = new Texture(context, image, context.gl.RGBA); + terrain.getFramebuffer('coords'); // allow init of frame buffers + terrain._fboCoordsTexture.texture = imageTexture.texture; + terrain.coordsIndex.push('abcd'); + + const coordinate = terrain.pointCoordinate(new Point(0, 0)); + + expect(coordinate).not.toBeNull(); + }); +}); diff --git a/src/render/terrain.ts b/src/render/terrain.ts new file mode 100644 index 00000000000..19a841ce27a --- /dev/null +++ b/src/render/terrain.ts @@ -0,0 +1,369 @@ + +import Tile from '../source/tile'; +import {mat4, vec2} from 'gl-matrix'; +import {OverscaledTileID} from '../source/tile_id'; +import {RGBAImage} from '../util/image'; +import {warnOnce} from '../util/util'; +import {PosArray, TriangleIndexArray} from '../data/array_types.g'; +import posAttributes from '../data/pos_attributes'; +import SegmentVector from '../data/segment'; +import VertexBuffer from '../gl/vertex_buffer'; +import IndexBuffer from '../gl/index_buffer'; +import Style from '../style/style'; +import Texture from '../render/texture'; +import type Framebuffer from '../gl/framebuffer'; +import Point from '@mapbox/point-geometry'; +import MercatorCoordinate from '../geo/mercator_coordinate'; +import TerrainSourceCache from '../source/terrain_source_cache'; +import SourceCache from '../source/source_cache'; +import EXTENT from '../data/extent'; +import {number as mix} from '../style-spec/util/interpolate'; +import type {TerrainSpecification} from '../style-spec/types.g'; + +/** + * This is the main class which handles most of the 3D Terrain logic. It has the follwing topics: + * 1) loads raster-dem tiles via the internal sourceCache this.sourceCache + * 2) creates a depth-framebuffer, which is used to calculate the visibility of coordinates + * 3) creates a coords-framebuffer, which is used the get to tile-coordinate for a screen-pixel + * 4) stores all render-to-texture tiles in the this.sourceCache._tiles + * 5) calculates the elevation for a spezific tile-coordinate + * 6) creates a terrain-mesh + * + * A note about the GPU resource-usage: + * Framebuffers: + * - one for the depth & coords framebuffer with the size of the map-div. + * - one for rendering a tile to texture with the size of tileSize (= 512x512). + * Textures: + * - one texture for an empty raster-dem tile with size 1x1 + * - one texture for an empty depth-buffer, when terrain is disabled with size 1x1 + * - one texture for an each loaded raster-dem with size of the source.tileSize + * - one texture for the coords-framebuffer with the size of the map-div. + * - one texture for the depth-framebuffer with the size of the map-div. + * - one texture for the encoded tile-coords with the size 2*tileSize (=1024x1024) + * - finally for each render-to-texture tile (= this._tiles) a set of textures + * for each render stack (The stack-concept is documented in painter.ts). + * Normally there exists 1-3 Textures per tile, depending on the stylesheet. + * Each Textures has the size 2*tileSize (= 1024x1024). Also there exists a + * cache of the last 150 newest rendered tiles. + * + */ + +export type TerrainData = { + 'u_depth': number; + 'u_terrain': number; + 'u_terrain_dim': number; + 'u_terrain_matrix': mat4; + 'u_terrain_unpack': number[]; + 'u_terrain_offset': number; + 'u_terrain_exaggeration': number; + texture: WebGLTexture; + depthTexture: WebGLTexture; + tile: Tile; +} + +export type TerrainMesh = { + indexBuffer: IndexBuffer; + vertexBuffer: VertexBuffer; + segments: SegmentVector; +} + +export default class Terrain { + // The style this terrain crresponds to + style: Style; + // the sourcecache this terrain is based on + sourceCache: TerrainSourceCache; + // the TerrainSpecification object passed to this instance + options: TerrainSpecification; + // define the meshSize per tile. + meshSize: number; + // multiplicator for the elevation. Used to make terrain more "extrem". + exaggeration: number; + // defines the global offset of putting negative elevations (e.g. dead-sea) into positive values. + elevationOffset: number; + // to not see pixels in the render-to-texture tiles it is good to render them bigger + // this number is the multiplicator (must be a power of 2) for the current tileSize. + // So to get good results with not too much memory footprint a value of 2 should be fine. + qualityFactor: number; + // holds the framebuffer object in size of the screen to render the coords & depth into a texture. + _fbo: Framebuffer; + _fboCoordsTexture: Texture; + _fboDepthTexture: Texture; + _emptyDepthTexture: Texture; + // GL Objects for the terrain-mesh + // The mesh is a regular mesh, which has the advantage that it can be reused for all tiles. + _mesh: TerrainMesh; + // coords index contains a list of tileID.keys. This index is used to identify + // the tile via the alpha-cannel in the coords-texture. + // As the alpha-channel has 1 Byte a max of 255 tiles can rendered without an error. + coordsIndex: Array; + // tile-coords encoded in the rgb channel, _coordsIndex is in the alpha-channel. + _coordsTexture: Texture; + // accuracy of the coords. 2 * tileSize should be enoughth. + _coordsTextureSize: number; + // variables for an empty dem texture, which is used while the raster-dem tile is loading. + _emptyDemUnpack: number[]; + _emptyDemTexture: Texture; + _emptyDemMatrix: mat4; + // as of overzooming of raster-dem tiles in high zoomlevels, this cache contains + // matrices to transform from vector-tile coords to raster-dem-tile coords. + _demMatrixCache: {[_: string]: { matrix: mat4; coord: OverscaledTileID }}; + // because of overzooming raster-dem tiles this cache holds the corresponding + // framebuffer-object to render tiles to texture + _rttFramebuffer: Framebuffer; + // loading raster-dem tiles foreach render-to-texture tile results in loading + // a lot of terrain-dem tiles with very low visual advantage. So with this setting + // remember all tiles which contains new data for a spezific source and tile-key. + _rerender: {[_: string]: {[_: number]: boolean}}; + + constructor(style: Style, sourceCache: SourceCache, options: TerrainSpecification) { + this.style = style; + this.sourceCache = new TerrainSourceCache(sourceCache); + this.options = options; + this.exaggeration = typeof options.exaggeration === 'number' ? options.exaggeration : 1.0; + this.elevationOffset = typeof options.elevationOffset === 'number' ? options.elevationOffset : 450; // ~ dead-sea + this.qualityFactor = 2; + this.meshSize = 128; + this._demMatrixCache = {}; + this.coordsIndex = []; + this._coordsTextureSize = 1024; + this.clearRerenderCache(); + } + + /** + * get the elevation-value from original dem-data for a given tile-coordinate + * @param {OverscaledTileID} tileID - the tile to get elevation for + * @param {number} x between 0 .. EXTENT + * @param {number} y between 0 .. EXTENT + * @param {number} extent optional, default 8192 + * @returns {number} - the elevation + */ + getDEMElevation(tileID: OverscaledTileID, x: number, y: number, extent: number = EXTENT): number { + if (!(x >= 0 && x < extent && y >= 0 && y < extent)) return this.elevationOffset; + let elevation = 0; + const terrain = this.getTerrainData(tileID); + if (terrain.tile && terrain.tile.dem) { + const pos = vec2.transformMat4([] as any, [x / extent * EXTENT, y / extent * EXTENT], terrain.u_terrain_matrix); + const coord = [ pos[0] * terrain.tile.dem.dim, pos[1] * terrain.tile.dem.dim ]; + const c = [ Math.floor(coord[0]), Math.floor(coord[1]) ]; + const tl = terrain.tile.dem.get(c[0], c[1]); + const tr = terrain.tile.dem.get(c[0], c[1] + 1); + const bl = terrain.tile.dem.get(c[0] + 1, c[1]); + const br = terrain.tile.dem.get(c[0] + 1, c[1] + 1); + elevation = mix(mix(tl, tr, coord[0] - c[0]), mix(bl, br, coord[0] - c[0]), coord[1] - c[1]); + } + return elevation; + } + + rememberForRerender(source: string, tileID: OverscaledTileID) { + for (const key in this.sourceCache._tiles) { + const tile = this.sourceCache._tiles[key]; + if (tile.tileID.equals(tileID) || tile.tileID.isChildOf(tileID)) { + if (source === this.sourceCache.sourceCache.id) tile.timeLoaded = Date.now(); + this._rerender[source] = this._rerender[source] || {}; + this._rerender[source][tile.tileID.key] = true; + } + } + } + + needsRerender(source: string, tileID: OverscaledTileID) { + return this._rerender[source] && this._rerender[source][tileID.key]; + } + + clearRerenderCache() { + this._rerender = {}; + } + + /** + * get the Elevation for given coordinate in respect of elevationOffset and exaggeration. + * @param {OverscaledTileID} tileID - the tile id + * @param {number} x between 0 .. EXTENT + * @param {number} y between 0 .. EXTENT + * @param {number} extent optional, default 8192 + * @returns {number} - the elevation + */ + getElevation(tileID: OverscaledTileID, x: number, y: number, extent: number = EXTENT): number { + return (this.getDEMElevation(tileID, x, y, extent) + this.elevationOffset) * this.exaggeration; + } + + /** + * returns a Terrain Object for a tile. Unless the tile corresponds to data (e.g. tile is loading), return a flat dem object + * @param {OverscaledTileID} tileID - the tile to get the terrain for + * @returns {TerrainData} the terrain data to use in the program + */ + getTerrainData(tileID: OverscaledTileID): TerrainData { + // create empty DEM Obejcts, which will used while raster-dem tiles are loading. + // creates an empty depth-buffer texture which is needed, during the initialisation process of the 3d mesh.. + if (!this._emptyDemTexture) { + const context = this.style.map.painter.context; + const image = new RGBAImage({width: 1, height: 1}, new Uint8Array(1 * 4)); + this._emptyDepthTexture = new Texture(context, image, context.gl.RGBA, {premultiply: false}); + this._emptyDemUnpack = [0, 0, 0, 0]; + this._emptyDemTexture = new Texture(context, new RGBAImage({width: 1, height: 1}), context.gl.RGBA, {premultiply: false}); + this._emptyDemTexture.bind(context.gl.NEAREST, context.gl.CLAMP_TO_EDGE); + this._emptyDemMatrix = mat4.identity([] as any); + } + // find covering dem tile and prepare demTexture + const sourceTile = this.sourceCache.getSourceTile(tileID, true); + if (sourceTile && sourceTile.dem && (!sourceTile.demTexture || sourceTile.needsTerrainPrepare)) { + const context = this.style.map.painter.context; + sourceTile.demTexture = this.style.map.painter.getTileTexture(sourceTile.dem.stride); + if (sourceTile.demTexture) sourceTile.demTexture.update(sourceTile.dem.getPixels(), {premultiply: false}); + else sourceTile.demTexture = new Texture(context, sourceTile.dem.getPixels(), context.gl.RGBA, {premultiply: false}); + sourceTile.demTexture.bind(context.gl.NEAREST, context.gl.CLAMP_TO_EDGE); + sourceTile.needsTerrainPrepare = false; + } + // create matrix for lookup in dem data + const matrixKey = sourceTile && (sourceTile + sourceTile.tileID.key) + tileID.key; + if (matrixKey && !this._demMatrixCache[matrixKey]) { + const maxzoom = this.sourceCache.sourceCache._source.maxzoom; + let dz = tileID.canonical.z - sourceTile.tileID.canonical.z; + if (tileID.overscaledZ > tileID.canonical.z) { + if (tileID.canonical.z >= maxzoom) dz = tileID.canonical.z - maxzoom; + else warnOnce('cannot calculate elevation if elevation maxzoom > source.maxzoom'); + } + const dx = tileID.canonical.x - (tileID.canonical.x >> dz << dz); + const dy = tileID.canonical.y - (tileID.canonical.y >> dz << dz); + const demMatrix = mat4.fromScaling(new Float64Array(16) as any, [1 / (EXTENT << dz), 1 / (EXTENT << dz), 0]); + mat4.translate(demMatrix, demMatrix, [dx * EXTENT, dy * EXTENT, 0]); + this._demMatrixCache[tileID.key] = {matrix: demMatrix, coord: tileID}; + } + // return uniform values & textures + return { + 'u_depth': 2, + 'u_terrain': 3, + 'u_terrain_dim': sourceTile && sourceTile.dem && sourceTile.dem.dim || 1, + 'u_terrain_matrix': matrixKey ? this._demMatrixCache[tileID.key].matrix : this._emptyDemMatrix, + 'u_terrain_unpack': sourceTile && sourceTile.dem && sourceTile.dem.getUnpackVector() || this._emptyDemUnpack, + 'u_terrain_offset': this.elevationOffset, + 'u_terrain_exaggeration': this.exaggeration, + texture: (sourceTile && sourceTile.demTexture || this._emptyDemTexture).texture, + depthTexture: (this._fboDepthTexture || this._emptyDepthTexture).texture, + tile: sourceTile + }; + } + + /** + * create the render-to-texture framebuffer + * @returns {Framebuffer} - the frame buffer + */ + getRTTFramebuffer() { + const painter = this.style.map.painter; + if (!this._rttFramebuffer) { + const size = this.sourceCache.tileSize * this.qualityFactor; + this._rttFramebuffer = painter.context.createFramebuffer(size, size, true); + this._rttFramebuffer.depthAttachment.set(painter.context.createRenderbuffer(painter.context.gl.DEPTH_COMPONENT16, size, size)); + } + return this._rttFramebuffer; + } + + /** + * get a framebuffer as big as the map-div, which will be used to render depth & coords into a texture + * @param {string} texture - the texture + * @returns {Framebuffer} the frame buffer + */ + getFramebuffer(texture: string): Framebuffer { + const painter = this.style.map.painter; + const width = painter.width / devicePixelRatio; + const height = painter.height / devicePixelRatio; + if (this._fbo && (this._fbo.width !== width || this._fbo.height !== height)) { + this._fbo.destroy(); + this._fboCoordsTexture.destroy(); + this._fboDepthTexture.destroy(); + delete this._fbo; + delete this._fboDepthTexture; + delete this._fboCoordsTexture; + } + if (!this._fboCoordsTexture) { + this._fboCoordsTexture = new Texture(painter.context, {width, height, data: null}, painter.context.gl.RGBA, {premultiply: false}); + this._fboCoordsTexture.bind(painter.context.gl.NEAREST, painter.context.gl.CLAMP_TO_EDGE); + } + if (!this._fboDepthTexture) { + this._fboDepthTexture = new Texture(painter.context, {width, height, data: null}, painter.context.gl.RGBA, {premultiply: false}); + this._fboDepthTexture.bind(painter.context.gl.NEAREST, painter.context.gl.CLAMP_TO_EDGE); + } + if (!this._fbo) { + this._fbo = painter.context.createFramebuffer(width, height, true); + this._fbo.depthAttachment.set(painter.context.createRenderbuffer(painter.context.gl.DEPTH_COMPONENT16, width, height)); + } + this._fbo.colorAttachment.set(texture === 'coords' ? this._fboCoordsTexture.texture : this._fboDepthTexture.texture); + return this._fbo; + } + + /** + * create coords texture, needed to grab coordinates from canvas + * encode coords coordinate into 4 bytes: + * - 8 lower bits for x + * - 8 lower bits for y + * - 4 higher bits for x + * - 4 higher bits for y + * - 8 bits for coordsIndex (1 .. 255) (= number of terraintile), is later setted in draw_terrain uniform value + * @returns {Texture} - the texture + */ + getCoordsTexture(): Texture { + const context = this.style.map.painter.context; + if (this._coordsTexture) return this._coordsTexture; + const data = new Uint8Array(this._coordsTextureSize * this._coordsTextureSize * 4); + for (let y = 0, i = 0; y < this._coordsTextureSize; y++) for (let x = 0; x < this._coordsTextureSize; x++, i += 4) { + data[i + 0] = x & 255; + data[i + 1] = y & 255; + data[i + 2] = ((x >> 8) << 4) | (y >> 8); + data[i + 3] = 0; + } + const image = new RGBAImage({width: this._coordsTextureSize, height: this._coordsTextureSize}, new Uint8Array(data.buffer)); + const texture = new Texture(context, image, context.gl.RGBA, {premultiply: false}); + texture.bind(context.gl.NEAREST, context.gl.CLAMP_TO_EDGE); + this._coordsTexture = texture; + return texture; + } + + /** + * Reads a pixel from the coords-framebuffer and translate this to mercator. + * @param {Point} p Screen-Coordinate + * @returns {MercatorCoordinate} mercator coordinate for a screen pixel + */ + pointCoordinate(p: Point): MercatorCoordinate { + const rgba = new Uint8Array(4); + const painter = this.style.map.painter, context = painter.context, gl = context.gl; + // grab coordinate pixel from coordinates framebuffer + context.bindFramebuffer.set(this.getFramebuffer('coords').framebuffer); + gl.readPixels(p.x, painter.height / devicePixelRatio - p.y - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, rgba); + context.bindFramebuffer.set(null); + // decode coordinates (encoding see getCoordsTexture) + const x = rgba[0] + ((rgba[2] >> 4) << 8); + 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; + const coordsSize = this._coordsTextureSize; + const worldSize = (1 << tile.tileID.canonical.z) * coordsSize; + return new MercatorCoordinate( + (tile.tileID.canonical.x * coordsSize + x) / worldSize, + (tile.tileID.canonical.y * coordsSize + y) / worldSize, + this.getElevation(tile.tileID, x, y, coordsSize) + ); + } + + /** + * create a regular mesh which will be used by all terrain-tiles + * @returns {TerrainMesh} - the created regular mesh + */ + getTerrainMesh(): TerrainMesh { + if (this._mesh) return this._mesh; + const context = this.style.map.painter.context; + const vertexArray = new PosArray(), indexArray = new TriangleIndexArray(); + const meshSize = this.meshSize, delta = EXTENT / meshSize, meshSize2 = meshSize * meshSize; + for (let y = 0; y <= meshSize; y++) for (let x = 0; x <= meshSize; x++) + vertexArray.emplaceBack(x * delta, y * delta); + for (let y = 0; y < meshSize2; y += meshSize + 1) for (let x = 0; x < meshSize; x++) { + indexArray.emplaceBack(x + y, meshSize + x + y + 1, meshSize + x + y + 2); + indexArray.emplaceBack(x + y, meshSize + x + y + 2, x + y + 1); + } + this._mesh = { + indexBuffer: context.createIndexBuffer(indexArray), + vertexBuffer: context.createVertexBuffer(vertexArray, posAttributes.members), + segments: SegmentVector.simpleSegment(0, 0, vertexArray.length, indexArray.length) + }; + return this._mesh; + } + +} diff --git a/src/render/vertex_array_object.ts b/src/render/vertex_array_object.ts index 236dba4c18b..80d875d027d 100644 --- a/src/render/vertex_array_object.ts +++ b/src/render/vertex_array_object.ts @@ -14,6 +14,7 @@ class VertexArrayObject { boundVertexOffset: number; boundDynamicVertexBuffer: VertexBuffer; boundDynamicVertexBuffer2: VertexBuffer; + boundDynamicVertexBuffer3: VertexBuffer; vao: any; constructor() { @@ -33,7 +34,8 @@ class VertexArrayObject { indexBuffer?: IndexBuffer | null, vertexOffset?: number | null, dynamicVertexBuffer?: VertexBuffer | null, - dynamicVertexBuffer2?: VertexBuffer | null) { + dynamicVertexBuffer2?: VertexBuffer | null, + dynamicVertexBuffer3?: VertexBuffer | null) { this.context = context; @@ -52,11 +54,12 @@ class VertexArrayObject { this.boundIndexBuffer !== indexBuffer || this.boundVertexOffset !== vertexOffset || this.boundDynamicVertexBuffer !== dynamicVertexBuffer || - this.boundDynamicVertexBuffer2 !== dynamicVertexBuffer2 + this.boundDynamicVertexBuffer2 !== dynamicVertexBuffer2 || + this.boundDynamicVertexBuffer3 !== dynamicVertexBuffer3 ); if (!context.extVertexArrayObject || isFreshBindRequired) { - this.freshBind(program, layoutVertexBuffer, paintVertexBuffers, indexBuffer, vertexOffset, dynamicVertexBuffer, dynamicVertexBuffer2); + this.freshBind(program, layoutVertexBuffer, paintVertexBuffers, indexBuffer, vertexOffset, dynamicVertexBuffer, dynamicVertexBuffer2, dynamicVertexBuffer3); } else { context.bindVertexArrayOES.set(this.vao); @@ -72,6 +75,10 @@ class VertexArrayObject { if (dynamicVertexBuffer2) { dynamicVertexBuffer2.bind(); } + + if (dynamicVertexBuffer3) { + dynamicVertexBuffer3.bind(); + } } } @@ -81,7 +88,9 @@ class VertexArrayObject { indexBuffer?: IndexBuffer | null, vertexOffset?: number | null, dynamicVertexBuffer?: VertexBuffer | null, - dynamicVertexBuffer2?: VertexBuffer | null) { + dynamicVertexBuffer2?: VertexBuffer | null, + dynamicVertexBuffer3?: VertexBuffer | null) { + let numPrevAttributes; const numNextAttributes = program.numAttributes; @@ -102,6 +111,7 @@ class VertexArrayObject { this.boundVertexOffset = vertexOffset; this.boundDynamicVertexBuffer = dynamicVertexBuffer; this.boundDynamicVertexBuffer2 = dynamicVertexBuffer2; + this.boundDynamicVertexBuffer3 = dynamicVertexBuffer3; } else { numPrevAttributes = context.currentNumAttributes || 0; @@ -127,6 +137,9 @@ class VertexArrayObject { if (dynamicVertexBuffer2) { dynamicVertexBuffer2.enableAttributes(gl, program); } + if (dynamicVertexBuffer3) { + dynamicVertexBuffer3.enableAttributes(gl, program); + } layoutVertexBuffer.bind(); layoutVertexBuffer.setVertexAttribPointers(gl, program, vertexOffset); @@ -146,6 +159,10 @@ class VertexArrayObject { dynamicVertexBuffer2.bind(); dynamicVertexBuffer2.setVertexAttribPointers(gl, program, vertexOffset); } + if (dynamicVertexBuffer3) { + dynamicVertexBuffer3.bind(); + dynamicVertexBuffer3.setVertexAttribPointers(gl, program, vertexOffset); + } context.currentNumAttributes = numNextAttributes; } diff --git a/src/shaders/_prelude.vertex.glsl b/src/shaders/_prelude.vertex.glsl index 9cd030e4eb9..2ae1de99624 100644 --- a/src/shaders/_prelude.vertex.glsl +++ b/src/shaders/_prelude.vertex.glsl @@ -71,3 +71,79 @@ vec2 get_pattern_pos(const vec2 pixel_coord_upper, const vec2 pixel_coord_lower, vec2 offset = mod(mod(mod(pixel_coord_upper, pattern_size) * 256.0, pattern_size) * 256.0 + pixel_coord_lower, pattern_size); return (tile_units_to_pixels * pos + offset) / pattern_size; } + +// logic for terrain 3d + +#ifdef TERRAIN3D +uniform sampler2D u_terrain; +uniform float u_terrain_dim; +uniform mat4 u_terrain_matrix; +uniform vec4 u_terrain_unpack; +uniform float u_terrain_offset; +uniform float u_terrain_exaggeration; +uniform highp sampler2D u_depth; +#endif + +// methods for pack/unpack depth value to texture rgba +// https://stackoverflow.com/questions/34963366/encode-floating-point-data-in-a-rgba-texture +const highp vec4 bitSh = vec4(256. * 256. * 256., 256. * 256., 256., 1.); +const highp vec4 bitShifts = vec4(1.) / bitSh; + +highp float unpack(highp vec4 color) { + return dot(color , bitShifts); +} + +// calculate the opacity behind terrain, returns a value between 0 and 1. +highp float depthOpacity(vec3 frag) { + #ifdef TERRAIN3D + // create the delta between frag.z + terrain.z. + highp float d = unpack(texture2D(u_depth, frag.xy * 0.5 + 0.5)) + 0.0001 - frag.z; + // visibility range is between 0 and 0.002. 0 is visible, 0.002 is fully invisible. + return 1.0 - max(0.0, min(1.0, -d * 500.0)); + #else + return 1.0; + #endif +} + +// calculate the visibility of a coordinate in terrain and return an opacity value. +// if a coordinate is behind the terrain reduce its opacity +float calculate_visibility(vec4 pos) { + #ifdef TERRAIN3D + vec3 frag = pos.xyz / pos.w; + // check if coordingate is fully visible + highp float d = depthOpacity(frag); + if (d > 0.95) return 1.0; + // if not, go some pixel above and check it this point is visible + return (d + depthOpacity(frag + vec3(0.0, 0.01, 0.0))) / 2.0; + #else + return 1.0; + #endif +} + +// grab an elevation value from a raster-dem texture +float ele(vec2 pos) { + #ifdef TERRAIN3D + vec4 rgb = (texture2D(u_terrain, pos) * 255.0) * u_terrain_unpack; + return rgb.r + rgb.g + rgb.b - u_terrain_unpack.a; + #else + return 0.0; + #endif +} + +// calculate the elevation with linear interpolation for a coordinate +float get_elevation(vec2 pos) { + #ifdef TERRAIN3D + vec2 coord = (u_terrain_matrix * vec4(pos, 0.0, 1.0)).xy * u_terrain_dim + 1.0; + vec2 f = fract(coord); + vec2 c = (floor(coord) + 0.5) / (u_terrain_dim + 2.0); // get the pixel center + float d = 1.0 / (u_terrain_dim + 2.0); + float tl = ele(c); + float tr = ele(c + vec2(d, 0.0)); + float bl = ele(c + vec2(0.0, d)); + float br = ele(c + vec2(d, d)); + float elevation = mix(mix(tl, tr, f.x), mix(bl, br, f.x), f.y); + return (elevation + u_terrain_offset) * u_terrain_exaggeration; + #else + return 0.0; + #endif +} diff --git a/src/shaders/circle.fragment.glsl b/src/shaders/circle.fragment.glsl index 14081450b09..a9f972539fb 100644 --- a/src/shaders/circle.fragment.glsl +++ b/src/shaders/circle.fragment.glsl @@ -1,4 +1,5 @@ varying vec3 v_data; +varying float v_visibility; #pragma mapbox: define highp vec4 color #pragma mapbox: define mediump float radius @@ -31,7 +32,7 @@ void main() { extrude_length - radius / (radius + stroke_width) ); - gl_FragColor = opacity_t * mix(color * opacity, stroke_color * stroke_opacity, color_t); + gl_FragColor = v_visibility * opacity_t * mix(color * opacity, stroke_color * stroke_opacity, color_t); #ifdef OVERDRAW_INSPECTOR gl_FragColor = vec4(1.0); diff --git a/src/shaders/circle.vertex.glsl b/src/shaders/circle.vertex.glsl index 1f084104174..32f0ad2ee74 100644 --- a/src/shaders/circle.vertex.glsl +++ b/src/shaders/circle.vertex.glsl @@ -8,6 +8,7 @@ uniform highp float u_camera_to_center_distance; attribute vec2 a_pos; varying vec3 v_data; +varying float v_visibility; #pragma mapbox: define highp vec4 color #pragma mapbox: define mediump float radius @@ -32,6 +33,9 @@ void main(void) { // multiply a_pos by 0.5, since we had it * 2 in order to sneak // in extrusion data vec2 circle_center = floor(a_pos * 0.5); + float ele = get_elevation(circle_center); + v_visibility = calculate_visibility(u_matrix * vec4(circle_center, ele, 1.0)); + if (u_pitch_with_map) { vec2 corner_position = circle_center; if (u_scale_with_map) { @@ -44,9 +48,9 @@ void main(void) { corner_position += extrude * (radius + stroke_width) * u_extrude_scale * (projected_center.w / u_camera_to_center_distance); } - gl_Position = u_matrix * vec4(corner_position, 0, 1); + gl_Position = u_matrix * vec4(corner_position, ele, 1); } else { - gl_Position = u_matrix * vec4(circle_center, 0, 1); + gl_Position = u_matrix * vec4(circle_center, ele, 1); 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/collision_box.vertex.glsl b/src/shaders/collision_box.vertex.glsl index 8fa4bfc025d..6107e234b23 100644 --- a/src/shaders/collision_box.vertex.glsl +++ b/src/shaders/collision_box.vertex.glsl @@ -19,7 +19,7 @@ void main() { 0.0, // Prevents oversized near-field boxes in pitched/overzoomed tiles 4.0); - gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); + gl_Position = u_matrix * vec4(a_pos, get_elevation(a_pos), 1.0); gl_Position.xy += (a_extrude + a_shift) * u_extrude_scale * gl_Position.w * collision_perspective_ratio; v_placed = a_placed.x; diff --git a/src/shaders/debug.vertex.glsl b/src/shaders/debug.vertex.glsl index c872e7fa981..f8635d8da0f 100644 --- a/src/shaders/debug.vertex.glsl +++ b/src/shaders/debug.vertex.glsl @@ -8,5 +8,5 @@ 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, 0, 1); + gl_Position = u_matrix * vec4(a_pos * u_overlay_scale, get_elevation(a_pos), 1); } diff --git a/src/shaders/fill_extrusion.vertex.glsl b/src/shaders/fill_extrusion.vertex.glsl index 7a771b6dc97..836e141f0b0 100644 --- a/src/shaders/fill_extrusion.vertex.glsl +++ b/src/shaders/fill_extrusion.vertex.glsl @@ -8,6 +8,11 @@ uniform lowp float u_opacity; attribute vec2 a_pos; attribute vec4 a_normal_ed; +#ifdef TERRAIN3D + attribute vec2 a_centroid; +#endif + + varying vec4 v_color; #pragma mapbox: define highp float base @@ -22,8 +27,17 @@ void main() { vec3 normal = a_normal_ed.xyz; - base = max(0.0, base); - height = max(0.0, height); + #ifdef TERRAIN3D + // To avoid floating buildings in 3d-terrain, especially in heavy terrain, + // render the buildings a little below terrain. The unit is meter. + float baseDelta = 10.0; + float ele = get_elevation(a_centroid); + #else + float baseDelta = 0.0; + float ele = 0.0; + #endif + base = max(0.0, ele + base - baseDelta); + height = max(0.0, ele + height); float t = mod(normal.x, 2.0); diff --git a/src/shaders/fill_extrusion_pattern.vertex.glsl b/src/shaders/fill_extrusion_pattern.vertex.glsl index 2428580f9ca..f04e109c910 100644 --- a/src/shaders/fill_extrusion_pattern.vertex.glsl +++ b/src/shaders/fill_extrusion_pattern.vertex.glsl @@ -13,6 +13,10 @@ uniform lowp float u_lightintensity; attribute vec2 a_pos; attribute vec4 a_normal_ed; +#ifdef TERRAIN3D + attribute vec2 a_centroid; +#endif + varying vec2 v_pos_a; varying vec2 v_pos_b; varying vec4 v_lighting; @@ -47,8 +51,17 @@ 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; - base = max(0.0, base); - height = max(0.0, height); + #ifdef TERRAIN3D + // To avoid floating buildings in 3d-terrain, especially in heavy terrain, + // render the buildings a little below terrain. The unit is meter. + float baseDelta = 10.0; + float ele = get_elevation(a_centroid); + #else + float baseDelta = 0.0; + float ele = 0.0; + #endif + base = max(0.0, ele + base - baseDelta); + height = max(0.0, ele + height); float t = mod(normal.x, 2.0); float z = t > 0.0 ? height : base; diff --git a/src/shaders/line.vertex.glsl b/src/shaders/line.vertex.glsl index 9334e2b72e1..13edbbe3864 100644 --- a/src/shaders/line.vertex.glsl +++ b/src/shaders/line.vertex.glsl @@ -77,9 +77,13 @@ void main() { gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; // calculate how much the perspective view squishes or stretches the extrude - float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); - v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #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); + v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #endif v_width2 = vec2(outset, inset); } diff --git a/src/shaders/line_gradient.vertex.glsl b/src/shaders/line_gradient.vertex.glsl index 4b02ba5337e..244ddaea891 100644 --- a/src/shaders/line_gradient.vertex.glsl +++ b/src/shaders/line_gradient.vertex.glsl @@ -80,9 +80,13 @@ void main() { gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; // calculate how much the perspective view squishes or stretches the extrude - float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); - v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #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); + v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #endif v_width2 = vec2(outset, inset); } diff --git a/src/shaders/line_pattern.vertex.glsl b/src/shaders/line_pattern.vertex.glsl index fff2daa110a..c1abacbab91 100644 --- a/src/shaders/line_pattern.vertex.glsl +++ b/src/shaders/line_pattern.vertex.glsl @@ -89,9 +89,13 @@ void main() { gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; // calculate how much the perspective view squishes or stretches the extrude - float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); - v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #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); + v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #endif v_linesofar = a_linesofar; v_width2 = vec2(outset, inset); diff --git a/src/shaders/line_sdf.vertex.glsl b/src/shaders/line_sdf.vertex.glsl index c85140ef7c4..3dcb1e914c5 100644 --- a/src/shaders/line_sdf.vertex.glsl +++ b/src/shaders/line_sdf.vertex.glsl @@ -87,12 +87,15 @@ void main() { gl_Position = u_matrix * vec4(pos + offset2 / u_ratio, 0.0, 1.0) + projected_extrude; // calculate how much the perspective view squishes or stretches the extrude - float extrude_length_without_perspective = length(dist); - float extrude_length_with_perspective = length(projected_extrude.xy / gl_Position.w * u_units_to_pixels); - v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #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); + v_gamma_scale = extrude_length_without_perspective / extrude_length_with_perspective; + #endif v_tex_a = vec2(a_linesofar * u_patternscale_a.x / floorwidth, normal.y * u_patternscale_a.y + u_tex_y_a); v_tex_b = vec2(a_linesofar * u_patternscale_b.x / floorwidth, normal.y * u_patternscale_b.y + u_tex_y_b); - v_width2 = vec2(outset, inset); } diff --git a/src/shaders/shaders.ts b/src/shaders/shaders.ts index 404f514a5b5..d5cb000c077 100644 --- a/src/shaders/shaders.ts +++ b/src/shaders/shaders.ts @@ -1,3 +1,6 @@ + +// Disable Flow annotations here because Flow doesn't support importing GLSL files + import preludeFrag from './_prelude.fragment.glsl.g'; import preludeVert from './_prelude.vertex.glsl.g'; import backgroundFrag from './background.fragment.glsl.g'; @@ -50,6 +53,10 @@ import symbolSDFFrag from './symbol_sdf.fragment.glsl.g'; import symbolSDFVert from './symbol_sdf.vertex.glsl.g'; import symbolTextAndIconFrag from './symbol_text_and_icon.fragment.glsl.g'; 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 terrainVert from './terrain.vertex.glsl.g'; export default { prelude: compile(preludeFrag, preludeVert), @@ -77,7 +84,10 @@ export default { raster: compile(rasterFrag, rasterVert), symbolIcon: compile(symbolIconFrag, symbolIconVert), symbolSDF: compile(symbolSDFFrag, symbolSDFVert), - symbolTextAndIcon: compile(symbolTextAndIconFrag, symbolTextAndIconVert) + symbolTextAndIcon: compile(symbolTextAndIconFrag, symbolTextAndIconVert), + terrain: compile(terrainFrag, terrainVert), + terrainDepth: compile(terrainDepthFrag, terrainVert), + terrainCoords: compile(terrainCoordsFrag, terrainVert) }; // Expand #pragmas to #ifdefs. diff --git a/src/shaders/symbol_icon.vertex.glsl b/src/shaders/symbol_icon.vertex.glsl index 16ab111429c..964cebc3dda 100644 --- a/src/shaders/symbol_icon.vertex.glsl +++ b/src/shaders/symbol_icon.vertex.glsl @@ -15,14 +15,11 @@ 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; uniform bool u_pitch_with_map; - uniform vec2 u_texsize; varying vec2 v_tex; @@ -43,6 +40,7 @@ void main() { vec2 a_pxoffset = a_pixeloffset.xy; vec2 a_minFontScale = a_pixeloffset.zw / 256.0; + float ele = get_elevation(a_pos); highp float segment_angle = -a_projected_pos[2]; float size; @@ -54,7 +52,7 @@ void main() { size = u_size; } - vec4 projectedPoint = u_matrix * vec4(a_pos, 0, 1); + vec4 projectedPoint = u_matrix * vec4(a_pos, ele, 1); highp float camera_to_anchor_distance = projectedPoint.w; // See comments in symbol_sdf.vertex highp float distance_ratio = u_pitch_with_map ? @@ -72,7 +70,7 @@ void main() { highp float symbol_rotation = 0.0; if (u_rotate_symbol) { // See comments in symbol_sdf.vertex - vec4 offsetProjectedPoint = u_matrix * vec4(a_pos + vec2(1, 0), 0, 1); + vec4 offsetProjectedPoint = u_matrix * vec4(a_pos + vec2(1, 0), ele, 1); vec2 a = projectedPoint.xy / projectedPoint.w; vec2 b = offsetProjectedPoint.xy / offsetProjectedPoint.w; @@ -84,11 +82,13 @@ void main() { highp float angle_cos = cos(segment_angle + symbol_rotation); mat2 rotation_matrix = mat2(angle_cos, -1.0 * angle_sin, angle_sin, angle_cos); - vec4 projected_pos = u_label_plane_matrix * vec4(a_projected_pos.xy, 0.0, 1.0); - gl_Position = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * max(a_minFontScale, fontScale) + a_pxoffset / 16.0), 0.0, 1.0); + vec4 projected_pos = u_label_plane_matrix * vec4(a_projected_pos.xy, ele, 1.0); + float z = float(u_pitch_with_map) * projected_pos.z / projected_pos.w; + gl_Position = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * max(a_minFontScale, fontScale) + a_pxoffset / 16.0), z, 1.0); v_tex = a_tex / u_texsize; vec2 fade_opacity = unpack_opacity(a_fade_opacity); float fade_change = fade_opacity[1] > 0.5 ? u_fade_change : -u_fade_change; - v_fade_opacity = max(0.0, min(1.0, fade_opacity[0] + fade_change)); + float visibility = calculate_visibility(projectedPoint); + v_fade_opacity = max(0.0, min(visibility, fade_opacity[0] + fade_change)); } diff --git a/src/shaders/symbol_sdf.vertex.glsl b/src/shaders/symbol_sdf.vertex.glsl index 71ccf3c81d7..3776cb6a3dc 100644 --- a/src/shaders/symbol_sdf.vertex.glsl +++ b/src/shaders/symbol_sdf.vertex.glsl @@ -54,6 +54,7 @@ void main() { float a_size_min = floor(a_size[0] * 0.5); vec2 a_pxoffset = a_pixeloffset.xy; + float ele = get_elevation(a_pos); highp float segment_angle = -a_projected_pos[2]; float size; @@ -65,7 +66,7 @@ void main() { size = u_size; } - vec4 projectedPoint = u_matrix * vec4(a_pos, 0, 1); + vec4 projectedPoint = u_matrix * vec4(a_pos, ele, 1); highp float camera_to_anchor_distance = projectedPoint.w; // If the label is pitched with the map, layout is done in pitched space, // which makes labels in the distance smaller relative to viewport space. @@ -90,7 +91,7 @@ void main() { // Point labels with 'rotation-alignment: map' are horizontal with respect to tile units // To figure out that angle in projected space, we draw a short horizontal line in tile // space, project it, and measure its angle in projected space. - vec4 offsetProjectedPoint = u_matrix * vec4(a_pos + vec2(1, 0), 0, 1); + vec4 offsetProjectedPoint = u_matrix * vec4(a_pos + vec2(1, 0), ele, 1); vec2 a = projectedPoint.xy / projectedPoint.w; vec2 b = offsetProjectedPoint.xy / offsetProjectedPoint.w; @@ -102,13 +103,15 @@ void main() { highp float angle_cos = cos(segment_angle + symbol_rotation); mat2 rotation_matrix = mat2(angle_cos, -1.0 * angle_sin, angle_sin, angle_cos); - vec4 projected_pos = u_label_plane_matrix * vec4(a_projected_pos.xy, 0.0, 1.0); - gl_Position = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * fontScale + a_pxoffset), 0.0, 1.0); + vec4 projected_pos = u_label_plane_matrix * vec4(a_projected_pos.xy, ele, 1.0); + float z = float(u_pitch_with_map) * projected_pos.z / projected_pos.w; + gl_Position = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * fontScale + a_pxoffset), z, 1.0); float gamma_scale = gl_Position.w; vec2 fade_opacity = unpack_opacity(a_fade_opacity); + float visibility = calculate_visibility(projectedPoint); float fade_change = fade_opacity[1] > 0.5 ? u_fade_change : -u_fade_change; - float interpolated_fade_opacity = max(0.0, min(1.0, fade_opacity[0] + fade_change)); + float interpolated_fade_opacity = max(0.0, min(visibility, fade_opacity[0] + fade_change)); v_data0 = a_tex / u_texsize; v_data1 = vec3(gamma_scale, size, interpolated_fade_opacity); diff --git a/src/shaders/symbol_text_and_icon.vertex.glsl b/src/shaders/symbol_text_and_icon.vertex.glsl index 647310fc9c9..4d85246acc8 100644 --- a/src/shaders/symbol_text_and_icon.vertex.glsl +++ b/src/shaders/symbol_text_and_icon.vertex.glsl @@ -54,6 +54,7 @@ void main() { float a_size_min = floor(a_size[0] * 0.5); float is_sdf = a_size[0] - 2.0 * a_size_min; + float ele = get_elevation(a_pos); highp float segment_angle = -a_projected_pos[2]; float size; @@ -65,7 +66,7 @@ void main() { size = u_size; } - vec4 projectedPoint = u_matrix * vec4(a_pos, 0, 1); + vec4 projectedPoint = u_matrix * vec4(a_pos, ele, 1); highp float camera_to_anchor_distance = projectedPoint.w; // If the label is pitched with the map, layout is done in pitched space, // which makes labels in the distance smaller relative to viewport space. @@ -90,7 +91,7 @@ void main() { // Point labels with 'rotation-alignment: map' are horizontal with respect to tile units // To figure out that angle in projected space, we draw a short horizontal line in tile // space, project it, and measure its angle in projected space. - vec4 offsetProjectedPoint = u_matrix * vec4(a_pos + vec2(1, 0), 0, 1); + vec4 offsetProjectedPoint = u_matrix * vec4(a_pos + vec2(1, 0), ele, 1); vec2 a = projectedPoint.xy / projectedPoint.w; vec2 b = offsetProjectedPoint.xy / offsetProjectedPoint.w; @@ -102,13 +103,15 @@ void main() { highp float angle_cos = cos(segment_angle + symbol_rotation); mat2 rotation_matrix = mat2(angle_cos, -1.0 * angle_sin, angle_sin, angle_cos); - vec4 projected_pos = u_label_plane_matrix * vec4(a_projected_pos.xy, 0.0, 1.0); - gl_Position = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * fontScale), 0.0, 1.0); + vec4 projected_pos = u_label_plane_matrix * vec4(a_projected_pos.xy, ele, 1.0); + float z = float(u_pitch_with_map) * projected_pos.z / projected_pos.w; + gl_Position = u_coord_matrix * vec4(projected_pos.xy / projected_pos.w + rotation_matrix * (a_offset / 32.0 * fontScale), z, 1.0); float gamma_scale = gl_Position.w; vec2 fade_opacity = unpack_opacity(a_fade_opacity); + float visibility = calculate_visibility(projectedPoint); float fade_change = fade_opacity[1] > 0.5 ? u_fade_change : -u_fade_change; - float interpolated_fade_opacity = max(0.0, min(1.0, fade_opacity[0] + fade_change)); + float interpolated_fade_opacity = max(0.0, min(visibility, fade_opacity[0] + fade_change)); v_data0.xy = a_tex / u_texsize; v_data0.zw = a_tex / u_texsize_icon; diff --git a/src/shaders/terrain.fragment.glsl b/src/shaders/terrain.fragment.glsl new file mode 100644 index 00000000000..14e2517dab8 --- /dev/null +++ b/src/shaders/terrain.fragment.glsl @@ -0,0 +1,7 @@ +uniform sampler2D u_texture; + +varying vec2 v_texture_pos; + +void main() { + gl_FragColor = texture2D(u_texture, v_texture_pos); +} diff --git a/src/shaders/terrain.vertex.glsl b/src/shaders/terrain.vertex.glsl new file mode 100644 index 00000000000..05b4d378d28 --- /dev/null +++ b/src/shaders/terrain.vertex.glsl @@ -0,0 +1,12 @@ +attribute vec2 a_pos; + +uniform mat4 u_matrix; + +varying vec2 v_texture_pos; +varying float v_depth; + +void main() { + v_texture_pos = a_pos / 8192.0; // 8192.0 is the hardcoded vector-tiles coordinates resolution + gl_Position = u_matrix * vec4(a_pos, get_elevation(a_pos), 1.0); + v_depth = gl_Position.z / gl_Position.w; +} diff --git a/src/shaders/terrain_coords.fragment.glsl b/src/shaders/terrain_coords.fragment.glsl new file mode 100644 index 00000000000..c4d8c7815be --- /dev/null +++ b/src/shaders/terrain_coords.fragment.glsl @@ -0,0 +1,11 @@ +precision mediump float; + +uniform sampler2D u_texture; +uniform float u_terrain_coords_id; + +varying vec2 v_texture_pos; + +void main() { + vec4 rgba = texture2D(u_texture, v_texture_pos); + gl_FragColor = vec4(rgba.r, rgba.g, rgba.b, u_terrain_coords_id); +} diff --git a/src/shaders/terrain_depth.fragment.glsl b/src/shaders/terrain_depth.fragment.glsl new file mode 100644 index 00000000000..ff5cf76d8c1 --- /dev/null +++ b/src/shaders/terrain_depth.fragment.glsl @@ -0,0 +1,15 @@ +varying float v_depth; + +// methods for pack/unpack depth value to texture rgba +// https://stackoverflow.com/questions/34963366/encode-floating-point-data-in-a-rgba-texture +const highp vec4 bitSh = vec4(256. * 256. * 256., 256. * 256., 256., 1.); +const highp vec4 bitMsk = vec4(0.,vec3(1./256.0)); +highp vec4 pack(highp float value) { + highp vec4 comp = fract(value * bitSh); + comp -= comp.xxyz * bitMsk; + return comp; +} + +void main() { + gl_FragColor = pack(v_depth); +} diff --git a/src/source/raster_dem_tile_source.ts b/src/source/raster_dem_tile_source.ts index 20b46215155..21825540370 100644 --- a/src/source/raster_dem_tile_source.ts +++ b/src/source/raster_dem_tile_source.ts @@ -70,15 +70,16 @@ class RasterDEMTileSource extends RasterTileSource implements Source { } } - function done(err, dem) { + function done(err, data) { if (err) { tile.state = 'errored'; callback(err); } - if (dem) { - tile.dem = dem; + if (data) { + tile.dem = data; tile.needsHillshadePrepare = true; + tile.needsTerrainPrepare = true; tile.state = 'loaded'; callback(null); } diff --git a/src/source/raster_dem_tile_worker_source.ts b/src/source/raster_dem_tile_worker_source.ts index 602a62dd769..f08fb1780cd 100644 --- a/src/source/raster_dem_tile_worker_source.ts +++ b/src/source/raster_dem_tile_worker_source.ts @@ -1,6 +1,5 @@ import DEMData from '../data/dem_data'; import {RGBAImage} from '../util/image'; - import type Actor from '../util/actor'; import type { WorkerDEMTileParameters, diff --git a/src/source/source_cache.test.ts b/src/source/source_cache.test.ts index 4d8d309c5b0..18a9351c117 100644 --- a/src/source/source_cache.test.ts +++ b/src/source/source_cache.test.ts @@ -1690,3 +1690,86 @@ describe('SourceCache#onRemove', () => { expect(sourceOnRemove).toHaveBeenCalled(); }); }); + +describe('SourceCache#usedForTerrain', () => { + test('loads covering tiles with usedForTerrain with source zoom 0-14', done => { + const transform = new Transform(); + transform.resize(511, 511); + transform.zoom = 10; + + const sourceCache = createSourceCache({}); + sourceCache.usedForTerrain = true; + sourceCache.tileSize = 1024; + expect(sourceCache.usedForTerrain).toBeTruthy(); + sourceCache.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + sourceCache.update(transform); + expect(Object.values(sourceCache._tiles).map(t => t.tileID.key)).toEqual( + ['2tc099', '2tbz99', '2sxs99', '2sxr99', 'pds88', 'eo55', 'pdr88', 'en55', 'p6o88', 'ds55', 'p6n88', 'dr55'] + ); + done(); + } + }); + sourceCache.onAdd(undefined); + }); + + test('loads covering tiles with usedForTerrain with source zoom 8-14', done => { + const transform = new Transform(); + transform.resize(511, 511); + transform.zoom = 10; + + const sourceCache = createSourceCache({minzoom: 8, maxzoom: 14}); + sourceCache.usedForTerrain = true; + sourceCache.tileSize = 1024; + sourceCache.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + sourceCache.update(transform); + expect(Object.values(sourceCache._tiles).map(t => t.tileID.key)).toEqual( + ['2tc099', '2tbz99', '2sxs99', '2sxr99', 'pds88', 'pdr88', 'p6o88', 'p6n88'] + ); + done(); + } + }); + sourceCache.onAdd(undefined); + }); + + test('loads covering tiles with usedForTerrain with source zoom 0-4', done => { + const transform = new Transform(); + transform.resize(511, 511); + transform.zoom = 10; + + const sourceCache = createSourceCache({minzoom: 0, maxzoom: 4}); + sourceCache.usedForTerrain = true; + sourceCache.tileSize = 1024; + sourceCache.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + sourceCache.update(transform); + expect(Object.values(sourceCache._tiles).map(t => t.tileID.key)).toEqual( + ['1033', '3s44', '3r44', '3c44', '3b44', 'z33', 's33', 'r33'] + ); + done(); + } + }); + sourceCache.onAdd(undefined); + }); + + test('loads covering tiles with usedForTerrain with source zoom 4-4', done => { + const transform = new Transform(); + transform.resize(511, 511); + transform.zoom = 10; + + const sourceCache = createSourceCache({minzoom: 4, maxzoom: 4}); + sourceCache.usedForTerrain = true; + sourceCache.tileSize = 1024; + sourceCache.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + sourceCache.update(transform); + expect(Object.values(sourceCache._tiles).map(t => t.tileID.key)).toEqual( + ['3s44', '3r44', '3c44', '3b44'] + ); + done(); + } + }); + sourceCache.onAdd(undefined); + }); +}); diff --git a/src/source/source_cache.ts b/src/source/source_cache.ts index bd70631d87f..ee5b41007bd 100644 --- a/src/source/source_cache.ts +++ b/src/source/source_cache.ts @@ -21,6 +21,8 @@ import type Transform from '../geo/transform'; import type {TileState} from './tile'; import type {Callback} from '../types/callback'; import type {SourceSpecification} from '../style-spec/types.g'; +import type {MapSourceDataEvent} from '../ui/events'; +import Terrain from '../render/terrain'; /** * `SourceCache` is responsible for @@ -56,7 +58,10 @@ class SourceCache extends Evented { _shouldReloadOnResume: boolean; _coveredTiles: {[_: string]: boolean}; transform: Transform; + terrain: Terrain; used: boolean; + usedForTerrain: boolean; + tileSize: number; _state: SourceFeatureState; _loadedParentTiles: {[_: string]: Tile}; @@ -68,7 +73,7 @@ class SourceCache extends Evented { this.id = id; this.dispatcher = dispatcher; - this.on('data', (e) => { + this.on('data', (e: MapSourceDataEvent) => { // this._sourceLoaded signifies that the TileJSON is loaded if applicable. // if the source type does not come with a TileJSON, the flag signifies the // source data has loaded (i.e geojson has been tiled on the worker and is ready) @@ -79,7 +84,7 @@ class SourceCache extends Evented { if (this._sourceLoaded && !this._paused && e.dataType === 'source' && e.sourceDataType === 'content') { this.reload(); if (this.transform) { - this.update(this.transform); + this.update(this.transform, this.terrain); } } }); @@ -152,7 +157,7 @@ class SourceCache extends Evented { this._paused = false; this._shouldReloadOnResume = false; if (shouldReload) this.reload(); - if (this.transform) this.update(this.transform); + if (this.transform) this.update(this.transform, this.terrain); } _loadTile(tile: Tile, callback: Callback) { @@ -263,7 +268,7 @@ class SourceCache extends Evented { tile.state = 'errored'; if ((err as any).status !== 404) this._source.fire(new ErrorEvent(err, {tile})); // continue to try loading parent/children tiles if a tile doesn't exist (404) - else this.update(this.transform); + else this.update(this.transform, this.terrain); return; } @@ -295,6 +300,7 @@ class SourceCache extends Evented { function fillBorder(tile, borderTile) { tile.needsHillshadePrepare = true; + tile.needsTerrainPrepare = true; let dx = borderTile.tileID.canonical.x - tile.tileID.canonical.x; const dy = borderTile.tileID.canonical.y - tile.tileID.canonical.y; const dim = Math.pow(2, tile.tileID.canonical.z); @@ -486,8 +492,9 @@ class SourceCache extends Evented { * are inside the viewport. * @private */ - update(transform: Transform) { + update(transform: Transform, terrain: Terrain) { this.transform = transform; + this.terrain = terrain; if (!this._sourceLoaded || this._paused) { return; } this.updateCacheSize(transform); @@ -498,18 +505,19 @@ class SourceCache extends Evented { this._coveredTiles = {}; let idealTileIDs; - if (!this.used) { + if (!this.used && !this.usedForTerrain) { idealTileIDs = []; } else if (this._source.tileID) { idealTileIDs = transform.getVisibleUnwrappedCoordinates(this._source.tileID) .map((unwrapped) => new OverscaledTileID(unwrapped.canonical.z, unwrapped.wrap, unwrapped.canonical.z, unwrapped.canonical.x, unwrapped.canonical.y)); } else { idealTileIDs = transform.coveringTiles({ - tileSize: this._source.tileSize, + tileSize: this.usedForTerrain ? this.tileSize : this._source.tileSize, minzoom: this._source.minzoom, maxzoom: this._source.maxzoom, - roundZoom: this._source.roundZoom, - reparseOverscaled: this._source.reparseOverscaled + roundZoom: this.usedForTerrain ? false : this._source.roundZoom, + reparseOverscaled: this._source.reparseOverscaled, + terrain }); if (this._source.hasTile) { @@ -522,6 +530,21 @@ class SourceCache extends Evented { const minCoveringZoom = Math.max(zoom - SourceCache.maxOverzooming, this._source.minzoom); const maxCoveringZoom = Math.max(zoom + SourceCache.maxUnderzooming, this._source.minzoom); + // When sourcecache is used for terrain also load parent tiles to avoid flickering when zooming out + if (this.usedForTerrain) { + const parents = {}; + for (const tileID of idealTileIDs) { + if (tileID.canonical.z > this._source.minzoom) { + const parent = tileID.scaledTo(tileID.canonical.z - 1); + parents[parent.key] = parent; + // load very low zoom to calculate tile visability in transform.coveringTiles and high zoomlevels correct + const parent2 = tileID.scaledTo(Math.max(this._source.minzoom, Math.min(tileID.canonical.z, 5))); + parents[parent2.key] = parent2; + } + } + idealTileIDs = idealTileIDs.concat(Object.values(parents)); + } + // Retain is a list of tiles that we shouldn't delete, even if they are not // the most ideal tile for the current viewport. This may include tiles like // parent or child tiles that are *already* loaded. @@ -558,6 +581,44 @@ class SourceCache extends Evented { retain[id] = parentsForFading[id]; } } + + // disable fading logic in terrain3D mode to avoid rendering two tiles on the same place + if (terrain) { + const idealRasterTileIDs: {[_: string]: OverscaledTileID} = {}; + const missingTileIDs: {[_: string]: OverscaledTileID} = {}; + for (const tileID of idealTileIDs) { + if (this._tiles[tileID.key].hasData()) + idealRasterTileIDs[tileID.key] = tileID; + else + missingTileIDs[tileID.key] = tileID; + } + // search for a complete set of children for each missing tile + for (const key in missingTileIDs) { + const children = missingTileIDs[key].children(this._source.maxzoom); + if (this._tiles[children[0].key] && this._tiles[children[1].key] && this._tiles[children[2].key] && this._tiles[children[3].key]) { + idealRasterTileIDs[children[0].key] = retain[children[0].key] = children[0]; + idealRasterTileIDs[children[1].key] = retain[children[1].key] = children[1]; + idealRasterTileIDs[children[2].key] = retain[children[2].key] = children[2]; + idealRasterTileIDs[children[3].key] = retain[children[3].key] = children[3]; + delete missingTileIDs[key]; + } + } + // search for parent for each missing tile + for (const key in missingTileIDs) { + const parent = this.findLoadedParent(missingTileIDs[key], this._source.minzoom); + if (parent) { + idealRasterTileIDs[parent.tileID.key] = retain[parent.tileID.key] = parent.tileID; + // remove idealTiles which would be rendered twice + for (const key in idealRasterTileIDs) { + if (idealRasterTileIDs[key].isChildOf(parent.tileID)) delete idealRasterTileIDs[key]; + } + } + } + // cover all tiles which are not needed + for (const key in this._tiles) { + if (!idealRasterTileIDs[key]) this._coveredTiles[key] = true; + } + } } for (const retainedId in retain) { @@ -819,8 +880,8 @@ class SourceCache extends Evented { transform.getCameraQueryGeometry(pointQueryGeometry) : pointQueryGeometry; - const queryGeometry = pointQueryGeometry.map((p) => transform.pointCoordinate(p)); - const cameraQueryGeometry = cameraPointQueryGeometry.map((p) => transform.pointCoordinate(p)); + const queryGeometry = pointQueryGeometry.map((p: Point) => transform.pointCoordinate(p, this.terrain)); + const cameraQueryGeometry = cameraPointQueryGeometry.map((p: Point) => transform.pointCoordinate(p, this.terrain)); const ids = this.getIds(); diff --git a/src/source/terrain_source_cache.test.ts b/src/source/terrain_source_cache.test.ts new file mode 100644 index 00000000000..053d30c5e4c --- /dev/null +++ b/src/source/terrain_source_cache.test.ts @@ -0,0 +1,89 @@ +import TerrainSourceCache from './terrain_source_cache'; +import Style from '../style/style'; +import {RequestManager} from '../util/request_manager'; +import Dispatcher from '../util/dispatcher'; +import {fakeServer, FakeServer} from 'nise'; +import Transform from '../geo/transform'; +import {Evented} from '../util/evented'; +import Painter from '../render/painter'; +import Context from '../gl/context'; +import gl from 'gl'; +import RasterDEMTileSource from './raster_dem_tile_source'; + +const context = new Context(gl(10, 10)); +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; + } +} + +function createSource(options, transformCallback?) { + const source = new RasterDEMTileSource('id', options, {send() {}} as any as Dispatcher, null); + source.onAdd({ + transform, + _getMapId: () => 1, + _requestManager: new RequestManager(transformCallback), + getPixelRatio() { return 1; } + } as any); + + source.on('error', (e) => { + throw e.error; + }); + + return source; +} + +describe('TerrainSourceCache', () => { + let server: FakeServer; + let style: Style; + let tsc: TerrainSourceCache; + + beforeAll(done => { + global.fetch = null; + server = fakeServer.create(); + server.respondWith('/source.json', JSON.stringify({ + minzoom: 0, + maxzoom: 22, + attribution: 'MapLibre', + tiles: ['http://example.com/{z}/{x}/{y}.pngraw'], + bounds: [-47, -7, -45, -5] + })); + const map = new StubMap(); + style = new Style(map as any); + style.map.painter = {style, context} as any; + style.on('style.load', () => { + const source = createSource({url: '/source.json'}); + server.respond(); + style.addSource('terrain', source as any); + tsc = new TerrainSourceCache(style.sourceCaches.terrain); + done(); + }); + style.loadJSON({ + 'version': 8, + 'sources': {}, + 'layers': [] + }); + }); + + afterAll(() => { + server.restore(); + }); + + test('#constructor', () => { + expect(tsc.sourceCache.usedForTerrain).toBeTruthy(); + expect(tsc.sourceCache.tileSize).toBe(tsc.tileSize * 2 ** tsc.deltaZoom); + }); + +}); diff --git a/src/source/terrain_source_cache.ts b/src/source/terrain_source_cache.ts new file mode 100644 index 00000000000..6ec9a12407e --- /dev/null +++ b/src/source/terrain_source_cache.ts @@ -0,0 +1,201 @@ +import {OverscaledTileID} from './tile_id'; +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 SourceCache from '../source/source_cache'; +import Painter from '../render/painter'; +import Terrain from '../render/terrain'; + +/** + * This class is a helper for the Terrain-class, it: + * - loads raster-dem tiles + * - manages all renderToTexture tiles. + * - caches previous rendered tiles. + * - finds all necessary renderToTexture tiles for a OverscaledTileID area + * - finds the corresponding raster-dem tile for OverscaledTileID + */ + +export default class TerrainSourceCache extends Evented { + // source-cache for the raster-dem source. + sourceCache: SourceCache; + // stores all render-to-texture tiles. + _tiles: {[_: string]: Tile}; + // contains a list of tileID-keys for the current scene. (only for performance) + _renderableTilesKeys: Array; + // raster-dem-tile for a TileID cache. + _sourceTileCache: {[_: string]: string}; + // minimum zoomlevel to render the terrain. + minzoom: number; + // maximum zoomlevel to render the terrain. + maxzoom: number; + // render-to-texture tileSize in scene. + tileSize: number; + // raster-dem tiles will load for performance the actualZoom - deltaZoom zoom-level. + deltaZoom: number; + // each time a render-to-texture tile is rendered, its tileID.key is stored into this array + renderHistory: Array; + // maximal size of render-history + renderHistorySize: number; + + constructor(sourceCache: SourceCache) { + super(); + this.sourceCache = sourceCache; + this._tiles = {}; + this._renderableTilesKeys = []; + this._sourceTileCache = {}; + this.renderHistory = []; + this.minzoom = 0; + this.maxzoom = 22; + this.tileSize = 512; + this.deltaZoom = 1; + this.renderHistorySize = 150; + sourceCache.usedForTerrain = true; + sourceCache.tileSize = this.tileSize * 2 ** this.deltaZoom; + } + + destruct() { + this.sourceCache.usedForTerrain = false; + this.sourceCache.tileSize = null; + for (const key in this._tiles) { + const tile = this._tiles[key]; + tile.textures.forEach(t => t.destroy()); + tile.textures = []; + } + } + + /** + * Load Terrain Tiles, create internal render-to-texture tiles, free GPU memory. + * @param {Transform} transform - the operation to do + * @param {Terrain} terrain - the terrain + */ + update(transform: Transform, 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. + this._renderableTilesKeys = []; + for (const tileID of transform.coveringTiles({ + tileSize: this.tileSize, + minzoom: this.minzoom, + maxzoom: this.maxzoom, + reparseOverscaled: false, + terrain + })) { + 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); + this._tiles[tileID.key] = new Tile(tileID, this.tileSize); + } + } + } + + /** + * This method should called before each render-to-texture step to free old cached tiles + * @param {Painter} painter - the painter + */ + removeOutdated(painter: Painter) { + // create lookuptable for actual needed tiles + const tileIDs = {}; + for (const key of this._renderableTilesKeys) tileIDs[key] = true; + // remove duplicates from renderHistory + this.renderHistory = this.renderHistory.filter((i, p) => this.renderHistory.indexOf(i) === p); + // free (GPU) memory from previously rendered not needed tiles + while (this.renderHistory.length > this.renderHistorySize) { + const tile = this.sourceCache._tiles[this.renderHistory.shift()]; + if (tile && !tileIDs[tile.tileID.key]) { + tile.clearTextures(painter); + delete this.sourceCache._tiles[tile.tileID.key]; + } + } + } + + /** + * get a list of tiles, which are loaded and should be rendered in the current scene + * @returns {Array} the renderable tiles + */ + getRenderableTiles(): Array { + return this._renderableTilesKeys.map(key => this.getTileByID(key)); + } + + /** + * get terrain tile by the TileID key + * @param id - the tile id + * @returns {Tile} - the tile + */ + getTileByID(id: string): Tile { + return this._tiles[id]; + } + + /** + * searches for the corresponding current renderable terrain-tiles + * @param {OverscaledTileID} tileID - the tile to look for + * @returns {[_:string]: Tile} - the tiles that were found + */ + getTerrainCoords(tileID: OverscaledTileID): {[_: string]: OverscaledTileID} { + const coords = {}; + for (const key of this._renderableTilesKeys) { + 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); + coords[key] = coord; + } else if (_tileID.canonical.isChildOf(tileID.canonical)) { + const coord = tileID.clone(); + coord.posMatrix = 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]); + coords[key] = coord; + } else if (tileID.canonical.isChildOf(_tileID.canonical)) { + const coord = tileID.clone(); + coord.posMatrix = 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]); + coords[key] = coord; + } + } + return coords; + } + + /** + * find the covering raster-dem tile + * @param {OverscaledTileID} tileID - the tile to look for + * @param {boolean} searchForDEM Optinal parameter to search for (parent) souretiles with loaded dem. + * @returns {Tile} - the tile + */ + getSourceTile(tileID: OverscaledTileID, searchForDEM?: boolean): Tile { + const source = this.sourceCache._source; + let z = tileID.overscaledZ - this.deltaZoom; + if (z > source.maxzoom) z = source.maxzoom; + if (z < source.minzoom) return null; + // cache for tileID to terrain-tileID + if (!this._sourceTileCache[tileID.key]) + this._sourceTileCache[tileID.key] = tileID.scaledTo(z).key; + let tile = this.sourceCache.getTileByID(this._sourceTileCache[tileID.key]); + // during tile-loading phase look if parent tiles (with loaded dem) are available. + if (!(tile && tile.dem) && searchForDEM) + while (z > source.minzoom && !(tile && tile.dem)) + tile = this.sourceCache.getTileByID(tileID.scaledTo(z--).key); + return tile; + } + + /** + * get a list of tiles, loaded after a spezific time. This is used to update depth & coords framebuffers. + * @param {Date} time - the time + * @returns {Array} - the relevant tiles + */ + tilesAfterTime(time = Date.now()): Array { + return Object.values(this._tiles).filter(t => t.timeLoaded >= time); + } +} diff --git a/src/source/tile.ts b/src/source/tile.ts index 8f234d2e91e..23e0fb58be5 100644 --- a/src/source/tile.ts +++ b/src/source/tile.ts @@ -65,6 +65,7 @@ class Tile { expiredRequestCount: number; state: TileState; timeAdded: any; + timeLoaded: any; fadeEndTime: any; collisionBoxArray: CollisionBoxArray; redoWhenDone: boolean; @@ -75,8 +76,10 @@ class Tile { neighboringTiles: any; dem: DEMData; + demMatrix: mat4; aborted: boolean; needsHillshadePrepare: boolean; + needsTerrainPrepare: boolean; request: Cancelable; texture: any; fbo: Framebuffer; @@ -90,6 +93,8 @@ class Tile { hasSymbolBuckets: boolean; hasRTLText: boolean; dependencies: any; + textures: Array; + textureCoords: {[_: string]: string}; // remeber all coords rendered to textures /** * @param {OverscaledTileID} tileID @@ -107,6 +112,8 @@ class Tile { this.hasSymbolBuckets = false; this.hasRTLText = false; this.dependencies = {}; + this.textures = []; + this.textureCoords = {}; // Counts the number of times a response was already expired when // received. We're using this to add a delay when making a new request @@ -129,6 +136,14 @@ class Tile { return this.state === 'errored' || this.state === 'loaded' || this.state === 'reloading'; } + clearTextures(painter: any) { + if (this.demTexture) painter.saveTileTexture(this.demTexture); + this.textures.forEach(t => painter.saveTileTexture(t)); + this.demTexture = null; + this.textures = []; + this.textureCoords = {}; + } + /** * Given a data object with a 'buffers' property, load it into * this tile's elementGroups and buffers properties and set loaded diff --git a/src/source/tile_id.ts b/src/source/tile_id.ts index 294c17617c1..4600733e4cb 100644 --- a/src/source/tile_id.ts +++ b/src/source/tile_id.ts @@ -42,6 +42,11 @@ export class CanonicalTileID { .replace(/{bbox-epsg-3857}/g, bbox); } + isChildOf(parent: CanonicalTileID) { + const dz = this.z - parent.z; + return dz > 0 && parent.x === (this.x >> dz) && parent.y === (this.y >> dz); + } + getTilePoint(coord: MercatorCoordinate) { const tilesAtZoom = Math.pow(2, this.z); return new Point( @@ -81,6 +86,10 @@ export class OverscaledTileID { this.key = calculateKey(wrap, overscaledZ, z, x, y); } + clone() { + return new OverscaledTileID(this.overscaledZ, this.wrap, this.canonical.z, this.canonical.x, this.canonical.y); + } + equals(id: OverscaledTileID) { return this.overscaledZ === id.overscaledZ && this.wrap === id.wrap && this.canonical.equals(id.canonical); } diff --git a/src/style-spec/error/validation_error.ts b/src/style-spec/error/validation_error.ts index 3b7cdf7be88..0c489cc396f 100644 --- a/src/style-spec/error/validation_error.ts +++ b/src/style-spec/error/validation_error.ts @@ -5,7 +5,7 @@ export default class ValidationError { identifier: string; line: number; - constructor(key: string, value: { + constructor(key: string, value: any & { __line__: number; }, message: string, identifier?: string | null) { this.message = (key ? `${key}: ` : '') + message; diff --git a/src/style-spec/reference/v8.json b/src/style-spec/reference/v8.json index b8e070a2d90..e4a7df58313 100644 --- a/src/style-spec/reference/v8.json +++ b/src/style-spec/reference/v8.json @@ -57,6 +57,15 @@ "intensity": 0.4 } }, + "terrain": { + "type": "terrain", + "doc": "The terrain configuration.", + "example": { + "source": "raster-dem-source", + "exaggeration": 0.5, + "elevationOffset": 100 + } + }, "sources": { "required": true, "type": "sources", @@ -3810,6 +3819,39 @@ } } }, + "terrain": { + "source": { + "type": "string", + "doc": "The source for the terrain data.", + "required": true, + "sdk-support": { + "basic functionality": { + "js": "2.2.0" + } + } + }, + "exaggeration": { + "type": "number", + "minimum": 0, + "doc": "The exaggeration of the terrain - how high it will look.", + "default": 1.0, + "sdk-support": { + "basic functionality": { + "js": "2.2.0" + } + } + }, + "elevationOffset": { + "type": "number", + "doc": "The elevation offset.", + "default": 450, + "sdk-support": { + "basic functionality": { + "js": "2.2.0" + } + } + } + }, "paint": [ "paint_fill", "paint_line", diff --git a/src/style-spec/validate/validate.ts b/src/style-spec/validate/validate.ts index 2c2c8aaaa1e..b827db3ba79 100644 --- a/src/style-spec/validate/validate.ts +++ b/src/style-spec/validate/validate.ts @@ -17,6 +17,7 @@ import validateFilter from './validate_filter'; import validateLayer from './validate_layer'; import validateSource from './validate_source'; import validateLight from './validate_light'; +import validateTerrain from './validate_terrain'; import validateString from './validate_string'; import validateFormatted from './validate_formatted'; import validateImage from './validate_image'; @@ -37,6 +38,7 @@ const VALIDATORS = { 'object': validateObject, 'source': validateSource, 'light': validateLight, + 'terrain': validateTerrain, 'string': validateString, 'formatted': validateFormatted, 'resolvedImage': validateImage diff --git a/src/style-spec/validate/validate_terrain.test.ts b/src/style-spec/validate/validate_terrain.test.ts new file mode 100644 index 00000000000..84cc507e1c0 --- /dev/null +++ b/src/style-spec/validate/validate_terrain.test.ts @@ -0,0 +1,46 @@ +import validateTerrain from './validate_terrain'; +import v8 from '../reference/v8.json'; + +describe('Validate Terrain', () => { + test('Should return error in case terrain is not an object', () => { + const errors = validateTerrain({value: 1 as any, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('number'); + expect(errors[0].message).toContain('object'); + expect(errors[0].message).toContain('terrain'); + }); + + test('Should return error in case terrain source is not a string', () => { + const errors = validateTerrain({value: {source: 1 as any}, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('number'); + expect(errors[0].message).toContain('string'); + expect(errors[0].message).toContain('source'); + }); + + test('Should return error in case of unknown property', () => { + const errors = validateTerrain({value: {a: 1} as any, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('a'); + expect(errors[0].message).toContain('unknown'); + }); + + test('Should return errors according to spec violations', () => { + const errors = validateTerrain({value: {source: 1 as any, exaggeration: {} as any, elevationOffset: 'ex2' as any}, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(3); + expect(errors[0].message).toContain('number'); + expect(errors[0].message).toContain('string'); + expect(errors[0].message).toContain('source'); + expect(errors[1].message).toContain('number'); + expect(errors[1].message).toContain('object'); + expect(errors[1].message).toContain('exaggeration'); + expect(errors[2].message).toContain('number'); + expect(errors[2].message).toContain('string'); + expect(errors[2].message).toContain('elevationOffset'); + }); + + test('Should pass if everything is according to spec', () => { + const errors = validateTerrain({value: {source: 'source-id', elevationOffset: 1, exaggeration: 0.2}, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(0); + }); +}); diff --git a/src/style-spec/validate/validate_terrain.ts b/src/style-spec/validate/validate_terrain.ts new file mode 100644 index 00000000000..f75153c87ea --- /dev/null +++ b/src/style-spec/validate/validate_terrain.ts @@ -0,0 +1,41 @@ +import ValidationError from '../error/validation_error'; +import getType from '../util/get_type'; +import validate from './validate'; +import type {StyleSpecification, TerrainSpecification} from '../types.g'; +import type v8 from '../reference/v8.json'; + +export default function validateTerrain( + options: {value: TerrainSpecification; styleSpec: typeof v8; style: StyleSpecification} +): ValidationError[] { + + const terrain = options.value; + const styleSpec = options.styleSpec; + const terrainSpec = styleSpec.terrain; + const style = options.style; + + let errors = []; + + const rootType = getType(terrain); + if (terrain === undefined) { + return errors; + } else if (rootType !== 'object') { + errors = errors.concat([new ValidationError('terrain', terrain, `object expected, ${rootType} found`)]); + return errors; + } + + for (const key in terrain) { + if (terrainSpec[key]) { + errors = errors.concat(validate({ + key, + value: terrain[key], + valueSpec: terrainSpec[key], + style, + styleSpec + })); + } else { + errors = errors.concat([new ValidationError(key, terrain[key], `unknown property "${key}"`)]); + } + } + + return errors; +} diff --git a/src/style-spec/validate_style.min.ts b/src/style-spec/validate_style.min.ts index 79ab747cf4a..816ed2806a0 100644 --- a/src/style-spec/validate_style.min.ts +++ b/src/style-spec/validate_style.min.ts @@ -6,6 +6,7 @@ import validateGlyphsURL from './validate/validate_glyphs_url'; import validateSource from './validate/validate_source'; import validateLight from './validate/validate_light'; +import validateTerrain from './validate/validate_terrain'; import validateLayer from './validate/validate_layer'; import validateFilter from './validate/validate_filter'; import validatePaintProperty from './validate/validate_paint_property'; @@ -58,6 +59,7 @@ function validateStyleMin(style, styleSpec = latestStyleSpec) { validateStyleMin.source = wrapCleanErrors(validateSource); validateStyleMin.light = wrapCleanErrors(validateLight); +validateStyleMin.terrain = wrapCleanErrors(validateTerrain); validateStyleMin.layer = wrapCleanErrors(validateLayer); validateStyleMin.filter = wrapCleanErrors(validateFilter); validateStyleMin.paintProperty = wrapCleanErrors(validatePaintProperty); diff --git a/src/style-spec/validate_style.ts b/src/style-spec/validate_style.ts index f8f83600892..1e809aa2a3a 100644 --- a/src/style-spec/validate_style.ts +++ b/src/style-spec/validate_style.ts @@ -33,6 +33,7 @@ export default function validateStyle(style, styleSpec = v8) { export const source = validateStyleMin.source; export const light = validateStyleMin.light; +export const terrain = validateStyleMin.terrain; export const layer = validateStyleMin.layer; export const filter = validateStyleMin.filter; export const paintProperty = validateStyleMin.paintProperty; diff --git a/src/style/pauseable_placement.ts b/src/style/pauseable_placement.ts index 2aeb6e7f805..7fc1b5c738e 100644 --- a/src/style/pauseable_placement.ts +++ b/src/style/pauseable_placement.ts @@ -7,6 +7,7 @@ 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'; class LayerPlacement { _sortAcrossTiles: boolean; @@ -69,6 +70,7 @@ class PauseablePlacement { constructor( transform: Transform, + terrain: Terrain, order: Array, forceFullPlacement: boolean, showCollisionBoxes: boolean, @@ -76,7 +78,7 @@ class PauseablePlacement { crossSourceCollisions: boolean, prevPlacement?: Placement ) { - this.placement = new Placement(transform, 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/style.test.ts b/src/style/style.test.ts index f12159b02e1..8cb705b17b3 100644 --- a/src/style/style.test.ts +++ b/src/style/style.test.ts @@ -379,6 +379,22 @@ describe('Style#loadJSON', () => { style._layers.background.fire(new Event('error', {mapLibre: true})); }); }); + + test('sets terrain if defined', (done) => { + const map = getStubMap(); + const style = new Style(map); + map.transform.updateElevation = jest.fn(); + style.loadJSON(createStyleJSON({ + sources: {'source-id': createGeoJSONSource()}, + terrain: {source: 'source-id', exaggeration: 0.33} + })); + + style.on('style.load', () => { + expect(style.terrain).toBeDefined(); + expect(map.transform.updateElevation).toHaveBeenCalled(); + done(); + }); + }); }); describe('Style#_remove', () => { diff --git a/src/style/style.ts b/src/style/style.ts index c773438b7d9..f257d0bf40c 100644 --- a/src/style/style.ts +++ b/src/style/style.ts @@ -57,11 +57,13 @@ import type { FilterSpecification, StyleSpecification, LightSpecification, - SourceSpecification + SourceSpecification, + TerrainSpecification } from '../style-spec/types.g'; import type {CustomLayerInterface} from './style_layer/custom_style_layer'; import type {Validator} from './validate_style'; import type {OverscaledTileID} from '../source/tile_id'; +import Terrain from '../render/terrain'; const supportedDiffOperations = pick(diffOperations, [ 'addLayer', @@ -102,6 +104,7 @@ export type StyleOptions = { export type StyleSetterOptions = { validate?: boolean; }; + /** * @private */ @@ -113,6 +116,7 @@ class Style extends Evented { glyphManager: GlyphManager; lineAtlas: LineAtlas; light: Light; + terrain: Terrain; _request: Cancelable; _spriteRequest: Cancelable; @@ -123,6 +127,8 @@ class Style extends Evented { zoomHistory: ZoomHistory; _loaded: boolean; _rtlTextPluginCallback: (a: any) => any; + _terrainDataCallback: (e: any) => any; + _terrainfreezeElevationCallback: (e: any) => any; _changed: boolean; _updatedSources: {[_: string]: 'clear' | 'reload'}; _updatedLayers: {[_: string]: true}; @@ -278,6 +284,8 @@ class Style extends Evented { this.light = new Light(this.stylesheet.light); + this.setTerrain(this.stylesheet.terrain); + this.fire(new Event('data', {dataType: 'style'})); this.fire(new Event('style.load')); } @@ -478,6 +486,52 @@ class Style extends Evented { this._changedImages = {}; } + /** + * Loads a 3D terrain mesh, based on a "raster-dem" source. + * @param {TerrainSpecification} [options] Options object. + */ + setTerrain(options?: TerrainSpecification) { + this._checkLoaded(); + + // clear event handlers + if (this._terrainDataCallback) this.off('data', this._terrainDataCallback); + if (this._terrainfreezeElevationCallback) this.map.off('freezeElevation', this._terrainfreezeElevationCallback); + + // remove terrain + if (!options) { + this.terrain = null; + this.map.transform.updateElevation(this.terrain); + + // add terrain + } else { + const sourceCache = this.sourceCaches[options.source]; + if (!sourceCache) throw new Error(`cannot load terrain, because there exists no source with ID: ${options.source}`); + this.terrain = new Terrain(this, sourceCache, options); + this.map.transform.updateElevation(this.terrain); + this._terrainfreezeElevationCallback = (e: any) => { + if (e.freeze) { + this.map.transform.freezeElevation = true; + } else { + this.map.transform.freezeElevation = false; + this.map.transform.recalculateZoom(this.terrain); + } + }; + this._terrainDataCallback = e => { + if (!e.tile) return; + if (e.sourceId === options.source) { + this.map.transform.updateElevation(this.terrain); + this.terrain.rememberForRerender(e.sourceId, e.tile.tileID); + } else if (e.source.type === 'geojson') { + this.terrain.rememberForRerender(e.sourceId, e.tile.tileID); + } + }; + this.on('data', this._terrainDataCallback); + this.map.on('freezeElevation', this._terrainfreezeElevationCallback); + } + + this.map.fire(new Event('terrain', {terrain: options})); + } + /** * Update this style's state to match the given style JSON, performing only * the necessary mutations. @@ -1259,7 +1313,7 @@ class Style extends Evented { _updateSources(transform: Transform) { for (const id in this.sourceCaches) { - this.sourceCaches[id].update(transform); + this.sourceCaches[id].update(transform, this.terrain); } } @@ -1300,7 +1354,7 @@ class Style extends Evented { forceFullPlacement = forceFullPlacement || this._layerOrderChanged || fadeDuration === 0; if (forceFullPlacement || !this.pauseablePlacement || (this.pauseablePlacement.isDone() && !this.placement.stillRecent(browser.now(), transform.zoom))) { - this.pauseablePlacement = new PauseablePlacement(transform, this._order, forceFullPlacement, showCollisionBoxes, fadeDuration, crossSourceCollisions, this.placement); + this.pauseablePlacement = new PauseablePlacement(transform, this.terrain, this._order, forceFullPlacement, showCollisionBoxes, fadeDuration, crossSourceCollisions, this.placement); this._layerOrderChanged = false; } diff --git a/src/style/validate_style.ts b/src/style/validate_style.ts index c257eea3b4b..81efa18fe17 100644 --- a/src/style/validate_style.ts +++ b/src/style/validate_style.ts @@ -15,6 +15,7 @@ type ValidateStyle = { source: Validator; layer: Validator; light: Validator; + terrain: Validator; filter: Validator; paintProperty: Validator; layoutProperty: Validator; @@ -25,6 +26,7 @@ export const validateStyle = (validateStyleMin as ValidateStyle); export const validateSource = validateStyle.source; export const validateLight = validateStyle.light; +export const validateTerrain = validateStyle.terrain; export const validateFilter = validateStyle.filter; export const validatePaintProperty = validateStyle.paintProperty; export const validateLayoutProperty = validateStyle.layoutProperty; diff --git a/src/symbol/collision_index.ts b/src/symbol/collision_index.ts index 289f894924e..d188ee4eb89 100644 --- a/src/symbol/collision_index.ts +++ b/src/symbol/collision_index.ts @@ -55,6 +55,10 @@ class CollisionIndex { gridRightBoundary: number; gridBottomBoundary: number; + // 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, grid = new GridIndex(transform.width + 2 * viewportPadding, transform.height + 2 * viewportPadding, 25), @@ -70,6 +74,8 @@ class CollisionIndex { this.screenBottomBoundary = transform.height + viewportPadding; this.gridRightBoundary = transform.width + 2 * viewportPadding; this.gridBottomBoundary = transform.height + 2 * viewportPadding; + + this.perspectiveRatioCutoff = 0.6; } placeCollisionBox( @@ -77,12 +83,13 @@ class CollisionIndex { overlapMode: OverlapMode, textPixelRatio: number, posMatrix: mat4, - collisionGroupPredicate?: (key: FeatureKey) => boolean + collisionGroupPredicate?: (key: FeatureKey) => boolean, + getElevation?: (x: number, y: number) => number ): { box: Array; offscreen: boolean; } { - const projectedPoint = this.projectAndGetPerspectiveRatio(posMatrix, collisionBox.anchorPointX, collisionBox.anchorPointY); + const projectedPoint = this.projectAndGetPerspectiveRatio(posMatrix, collisionBox.anchorPointX, collisionBox.anchorPointY, getElevation); const tileToViewport = textPixelRatio * projectedPoint.perspectiveRatio; const tlX = collisionBox.x1 * tileToViewport + projectedPoint.point.x; const tlY = collisionBox.y1 * tileToViewport + projectedPoint.point.y; @@ -90,7 +97,8 @@ class CollisionIndex { const brY = collisionBox.y2 * tileToViewport + projectedPoint.point.y; if (!this.isInsideGrid(tlX, tlY, brX, brY) || - (overlapMode !== 'always' && this.grid.hitTest(tlX, tlY, brX, brY, overlapMode, collisionGroupPredicate))) { + (overlapMode !== 'always' && this.grid.hitTest(tlX, tlY, brX, brY, overlapMode, collisionGroupPredicate)) || + projectedPoint.perspectiveRatio < this.perspectiveRatioCutoff) { return { box: [], offscreen: false @@ -116,7 +124,8 @@ class CollisionIndex { pitchWithMap: boolean, collisionGroupPredicate: (key: FeatureKey) => boolean, circlePixelDiameter: number, - textPixelPadding: number + textPixelPadding: number, + getElevation: (x: number, y: number) => number ): { circles: Array; offscreen: boolean; @@ -125,12 +134,12 @@ class CollisionIndex { const placedCollisionCircles = []; const tileUnitAnchorPoint = new Point(symbol.anchorX, symbol.anchorY); - const screenAnchorPoint = projection.project(tileUnitAnchorPoint, posMatrix); + const screenAnchorPoint = projection.project(tileUnitAnchorPoint, posMatrix, getElevation); const perspectiveRatio = projection.getPerspectiveRatio(this.transform.cameraToCenterDistance, screenAnchorPoint.signedDistanceFromCamera); const labelPlaneFontSize = pitchWithMap ? fontSize / perspectiveRatio : fontSize * perspectiveRatio; const labelPlaneFontScale = labelPlaneFontSize / ONE_EM; - const labelPlaneAnchorPoint = projection.project(tileUnitAnchorPoint, labelPlaneMatrix).point; + const labelPlaneAnchorPoint = projection.project(tileUnitAnchorPoint, labelPlaneMatrix, getElevation).point; const projectionCache = {}; const lineOffsetX = symbol.lineOffsetX * labelPlaneFontScale; @@ -148,7 +157,8 @@ class CollisionIndex { lineVertexArray, labelPlaneMatrix, projectionCache, - false); + false, + getElevation); let collisionDetected = false; let inGrid = false; @@ -178,7 +188,7 @@ class CollisionIndex { // The path might need to be converted into screen space if a pitched map is used as the label space if (labelToScreenMatrix) { - const screenSpacePath = projectedPath.map(p => projection.project(p, labelToScreenMatrix)); + const screenSpacePath = projectedPath.map(p => projection.project(p, labelToScreenMatrix, getElevation)); // Do not try to place collision circles if even of the points is behind the camera. // This is a plausible scenario with big camera pitch angles @@ -265,7 +275,7 @@ class CollisionIndex { } return { - circles: ((!showCollisionCircles && collisionDetected) || !inGrid) ? [] : placedCollisionCircles, + circles: ((!showCollisionCircles && collisionDetected) || !inGrid || perspectiveRatio < this.perspectiveRatioCutoff) ? [] : placedCollisionCircles, offscreen: entirelyOffscreen, collisionDetected }; @@ -354,9 +364,15 @@ class CollisionIndex { } } - projectAndGetPerspectiveRatio(posMatrix: mat4, x: number, y: number) { - const p = [x, y, 0, 1] as vec4; - projection.xyTransformMat4(p, p, posMatrix); + projectAndGetPerspectiveRatio(posMatrix: mat4, x: number, y: number, getElevation: (x: number, y: number) => number) { + let p; + if (getElevation) { // slow because of handle z-index + p = [x, y, getElevation(x, y), 1] as vec4; + vec4.transformMat4(p, p, posMatrix); + } else { // fast because of ignore z-index + p = [x, y, 0, 1] as vec4; + projection.xyTransformMat4(p, p, posMatrix); + } const a = new Point( (((p[0] / p[3] + 1) / 2) * this.transform.width) + viewportPadding, (((-p[1] / p[3] + 1) / 2) * this.transform.height) + viewportPadding diff --git a/src/symbol/placement.ts b/src/symbol/placement.ts index 64801c9bd4a..bc826c3a3d4 100644 --- a/src/symbol/placement.ts +++ b/src/symbol/placement.ts @@ -22,6 +22,7 @@ import type {CollisionBoxArray, CollisionVertexArray, SymbolInstance} from '../d import type FeatureIndex from '../data/feature_index'; import type {OverscaledTileID} from '../source/tile_id'; import type {TextAnchor} from './symbol_layout'; +import Terrain from '../render/terrain'; class OpacityState { opacity: number; @@ -210,6 +211,7 @@ export type CrossTileID = string | number; export class Placement { transform: Transform; + terrain: Terrain; collisionIndex: CollisionIndex; placements: { [_ in CrossTileID]: JointPlacement; @@ -238,8 +240,9 @@ export class Placement { [k in any]: CollisionCircleArray; }; - constructor(transform: Transform, fadeDuration: number, crossSourceCollisions: boolean, prevPlacement?: Placement) { + constructor(transform: Transform, terrain: Terrain, fadeDuration: number, crossSourceCollisions: boolean, prevPlacement?: Placement) { this.transform = transform.clone(); + this.terrain = terrain; this.collisionIndex = new CollisionIndex(this.transform); this.placements = {}; this.opacities = {}; @@ -350,7 +353,8 @@ export class Placement { symbolInstance: SymbolInstance, bucket: SymbolBucket, orientation: number, - iconBox?: SingleCollisionBox | null + iconBox?: SingleCollisionBox | null, + getElevation?: (x: number, y: number) => number ): { shift: Point; placedGlyphBoxes: { @@ -366,14 +370,14 @@ export class Placement { shiftVariableCollisionBox( textBox, shift.x, shift.y, rotateWithMap, pitchWithMap, this.transform.angle), - textOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate); + textOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate, getElevation); if (iconBox) { const placedIconBoxes = this.collisionIndex.placeCollisionBox( shiftVariableCollisionBox( iconBox, shift.x, shift.y, rotateWithMap, pitchWithMap, this.transform.angle), - textOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate); + textOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate, getElevation); if (placedIconBoxes.box.length === 0) return; } @@ -489,6 +493,14 @@ export class Placement { verticalTextFeatureIndex = collisionArrays.verticalTextFeatureIndex; } + // update elevation of collisionArrays + const tileID = this.retainedQueryData[bucket.bucketInstanceId].tileID; + const getElevation = this.terrain ? (x: number, y: number) => this.terrain.getElevation(tileID, x, y) : null; + for (const boxType of ['textBox', 'verticalTextBox', 'iconBox', 'verticalIconBox']) { + const box = collisionArrays[boxType]; + if (box) box.elevation = getElevation ? getElevation(box.anchorPointX, box.anchorPointY) : 0; + } + const textBox = collisionArrays.textBox; if (textBox) { @@ -528,7 +540,9 @@ export class Placement { textOverlapMode, textPixelRatio, posMatrix, - collisionGroup.predicate); + collisionGroup.predicate, + getElevation + ); if (placedFeature && placedFeature.box && placedFeature.box.length) { this.markUsedOrientation(bucket, orientation, symbolInstance); this.placedOrientations[symbolInstance.crossTileID] = orientation; @@ -583,7 +597,7 @@ export class Placement { const result = this.attemptAnchorPlacement( anchor, collisionTextBox, width, height, textBoxScale, rotateWithMap, pitchWithMap, textPixelRatio, posMatrix, - collisionGroup, overlapMode, symbolInstance, bucket, orientation, variableIconBox); + collisionGroup, overlapMode, symbolInstance, bucket, orientation, variableIconBox, getElevation); if (result) { placedBox = result.placedGlyphBoxes; @@ -658,7 +672,9 @@ export class Placement { pitchWithMap, collisionGroup.predicate, circlePixelDiameter, - textPixelPadding); + textPixelPadding, + getElevation + ); assert(!placedGlyphCircles.circles.length || (!placedGlyphCircles.collisionDetected || showCollisionBoxes)); // If text-overlap is set to 'always', force "placedCircles" to true @@ -674,7 +690,6 @@ export class Placement { } if (collisionArrays.iconBox) { - const placeIconFeature = iconBox => { const shiftedIconBox = hasIconTextFit && shift ? shiftVariableCollisionBox( @@ -682,7 +697,7 @@ export class Placement { rotateWithMap, pitchWithMap, this.transform.angle) : iconBox; return this.collisionIndex.placeCollisionBox(shiftedIconBox, - iconOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate); + iconOverlapMode, textPixelRatio, posMatrix, collisionGroup.predicate, getElevation); }; if (placedVerticalText && placedVerticalText.box && placedVerticalText.box.length && collisionArrays.verticalIconBox) { diff --git a/src/symbol/projection.ts b/src/symbol/projection.ts index 9608b1a54df..8cbd90d175b 100644 --- a/src/symbol/projection.ts +++ b/src/symbol/projection.ts @@ -101,9 +101,15 @@ function getGlCoordMatrix(posMatrix: mat4, } } -function project(point: Point, matrix: mat4) { - const pos = [point.x, point.y, 0, 1] as vec4; - xyTransformMat4(pos, pos, matrix); +function project(point: Point, matrix: mat4, getElevation: (x: number, y: number) => number) { + let pos; + if (getElevation) { // slow because of handle z-index + pos = [point.x, point.y, getElevation(point.x, point.y), 1] as vec4; + vec4.transformMat4(pos, pos, matrix); + } else { // fast because of ignore z-index + pos = [point.x, point.y, 0, 1] as vec4; + xyTransformMat4(pos, pos, matrix); + } const w = pos[3]; return { point: new Point(pos[0] / w, pos[1] / w), @@ -139,7 +145,8 @@ function updateLineLabels(bucket: SymbolBucket, glCoordMatrix: mat4, pitchWithMap: boolean, keepUpright: boolean, - rotateToLine: boolean) { + rotateToLine: boolean, + getElevation: (x: number, y: number) => number) { const sizeData = isText ? bucket.textSizeData : bucket.iconSizeData; const partiallyEvaluatedSize = symbolSize.evaluateSizeForZoom(sizeData, painter.transform.zoom); @@ -171,8 +178,14 @@ function updateLineLabels(bucket: SymbolBucket, // Awkward... but we're counting on the paired "vertical" symbol coming immediately after its horizontal counterpart useVertical = false; - const anchorPos = [symbol.anchorX, symbol.anchorY, 0, 1] as vec4; - vec4.transformMat4(anchorPos, anchorPos, posMatrix); + let anchorPos; + if (getElevation) { // slow because of handle z-index + anchorPos = [symbol.anchorX, symbol.anchorY, getElevation(symbol.anchorX, symbol.anchorY), 1] as vec4; + vec4.transformMat4(anchorPos, anchorPos, posMatrix); + } else { // fast because of ignore z-index + anchorPos = [symbol.anchorX, symbol.anchorY, 0, 1] as vec4; + xyTransformMat4(anchorPos, anchorPos, posMatrix); + } // Don't bother calculating the correct point for invisible labels. if (!isVisible(anchorPos, clippingBuffer)) { @@ -187,18 +200,18 @@ function updateLineLabels(bucket: SymbolBucket, const pitchScaledFontSize = pitchWithMap ? fontSize / perspectiveRatio : fontSize * perspectiveRatio; const tileAnchorPoint = new Point(symbol.anchorX, symbol.anchorY); - const anchorPoint = project(tileAnchorPoint, labelPlaneMatrix).point; + const anchorPoint = project(tileAnchorPoint, labelPlaneMatrix, getElevation).point; const projectionCache = {}; const placeUnflipped: any = placeGlyphsAlongLine(symbol, pitchScaledFontSize, false /*unflipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, - bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache, aspectRatio, rotateToLine); + bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache, aspectRatio, rotateToLine, getElevation); useVertical = placeUnflipped.useVertical; if (placeUnflipped.notEnoughRoom || useVertical || (placeUnflipped.needsFlipping && (placeGlyphsAlongLine(symbol, pitchScaledFontSize, true /*flipped*/, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, - bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache, aspectRatio, rotateToLine) as any).notEnoughRoom)) { + bucket.glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache, aspectRatio, rotateToLine, getElevation) as any).notEnoughRoom)) { hideGlyphs(symbol.numGlyphs, dynamicLayoutVertexArray); } } @@ -210,7 +223,7 @@ function updateLineLabels(bucket: SymbolBucket, } } -function placeFirstAndLastGlyph(fontScale: number, glyphOffsetArray: GlyphOffsetArray, lineOffsetX: number, lineOffsetY: number, flip: boolean, anchorPoint: Point, tileAnchorPoint: Point, symbol: any, lineVertexArray: SymbolLineVertexArray, labelPlaneMatrix: mat4, projectionCache: any, rotateToLine: boolean) { +function placeFirstAndLastGlyph(fontScale: number, glyphOffsetArray: GlyphOffsetArray, lineOffsetX: number, lineOffsetY: number, flip: boolean, anchorPoint: Point, tileAnchorPoint: Point, symbol: any, lineVertexArray: SymbolLineVertexArray, labelPlaneMatrix: mat4, projectionCache: any, rotateToLine: boolean, getElevation: (x: number, y: number) => number) { const glyphEndIndex = symbol.glyphStartIndex + symbol.numGlyphs; const lineStartIndex = symbol.lineStartIndex; const lineEndIndex = symbol.lineStartIndex + symbol.lineLength; @@ -219,12 +232,12 @@ function placeFirstAndLastGlyph(fontScale: number, glyphOffsetArray: GlyphOffset const lastGlyphOffset = glyphOffsetArray.getoffsetX(glyphEndIndex - 1); const firstPlacedGlyph = placeGlyphAlongLine(fontScale * firstGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine); + lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine, getElevation); if (!firstPlacedGlyph) return null; const lastPlacedGlyph = placeGlyphAlongLine(fontScale * lastGlyphOffset, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine); + lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine, getElevation); if (!lastPlacedGlyph) return null; @@ -252,7 +265,7 @@ function requiresOrientationChange(writingMode, firstPoint, lastPoint, aspectRat return null; } -function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache, aspectRatio, rotateToLine) { +function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, labelPlaneMatrix, glCoordMatrix, glyphOffsetArray, lineVertexArray, dynamicLayoutVertexArray, anchorPoint, tileAnchorPoint, projectionCache, aspectRatio, rotateToLine, getElevation) { const fontScale = fontSize / 24; const lineOffsetX = symbol.lineOffsetX * fontScale; const lineOffsetY = symbol.lineOffsetY * fontScale; @@ -265,12 +278,12 @@ function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, la // 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 - const firstAndLastGlyph = placeFirstAndLastGlyph(fontScale, glyphOffsetArray, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine); + const firstAndLastGlyph = placeFirstAndLastGlyph(fontScale, glyphOffsetArray, lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine, getElevation); if (!firstAndLastGlyph) { return {notEnoughRoom: true}; } - const firstPoint = project(firstAndLastGlyph.first.point, glCoordMatrix).point; - const lastPoint = project(firstAndLastGlyph.last.point, glCoordMatrix).point; + const firstPoint = project(firstAndLastGlyph.first.point, glCoordMatrix, getElevation).point; + const lastPoint = project(firstAndLastGlyph.last.point, glCoordMatrix, getElevation).point; if (keepUpright && !flip) { const orientationChange = requiresOrientationChange(symbol.writingMode, firstPoint, lastPoint, aspectRatio); @@ -284,24 +297,24 @@ function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, la // Since first and last glyph fit on the line, we're sure that the rest of the glyphs can be placed // $FlowFixMe placedGlyphs.push(placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(glyphIndex), lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine)); + lineStartIndex, lineEndIndex, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine, getElevation)); } placedGlyphs.push(firstAndLastGlyph.last); } else { // 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(tileAnchorPoint, posMatrix).point; + const a = project(tileAnchorPoint, posMatrix, getElevation).point; const tileVertexIndex = (symbol.lineStartIndex + symbol.segment + 1); // $FlowFixMe const tileSegmentEnd = new Point(lineVertexArray.getx(tileVertexIndex), lineVertexArray.gety(tileVertexIndex)); - const projectedVertex = project(tileSegmentEnd, posMatrix); + const projectedVertex = project(tileSegmentEnd, posMatrix, getElevation); // 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 : - projectTruncatedLineSegment(tileAnchorPoint, tileSegmentEnd, a, 1, posMatrix); + projectTruncatedLineSegment(tileAnchorPoint, tileSegmentEnd, a, 1, posMatrix, getElevation); const orientationChange = requiresOrientationChange(symbol.writingMode, a, b, aspectRatio); if (orientationChange) { @@ -310,7 +323,7 @@ function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, la } // $FlowFixMe const singleGlyph = placeGlyphAlongLine(fontScale * glyphOffsetArray.getoffsetX(symbol.glyphStartIndex), lineOffsetX, lineOffsetY, flip, anchorPoint, tileAnchorPoint, symbol.segment, - symbol.lineStartIndex, symbol.lineStartIndex + symbol.lineLength, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine); + symbol.lineStartIndex, symbol.lineStartIndex + symbol.lineLength, lineVertexArray, labelPlaneMatrix, projectionCache, rotateToLine, getElevation); if (!singleGlyph) return {notEnoughRoom: true}; @@ -323,18 +336,19 @@ function placeGlyphsAlongLine(symbol, fontSize, flip, keepUpright, posMatrix, la return {}; } -function projectTruncatedLineSegment(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Point, minimumLength: number, projectionMatrix: mat4) { +function projectTruncatedLineSegment(previousTilePoint: Point, currentTilePoint: Point, previousProjectedPoint: Point, minimumLength: number, projectionMatrix: mat4, getElevation: (x: number, y: number) => number) { // 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 projectedUnitVertex = project(previousTilePoint.add(previousTilePoint.sub(currentTilePoint)._unit()), projectionMatrix).point; + const projectedUnitVertex = project(previousTilePoint.add(previousTilePoint.sub(currentTilePoint)._unit()), projectionMatrix, getElevation).point; const projectedUnitSegment = previousProjectedPoint.sub(projectedUnitVertex); return previousProjectedPoint.add(projectedUnitSegment._mult(minimumLength / projectedUnitSegment.mag())); } -function placeGlyphAlongLine(offsetX: number, +function placeGlyphAlongLine( + offsetX: number, lineOffsetX: number, lineOffsetY: number, flip: boolean, @@ -348,7 +362,8 @@ function placeGlyphAlongLine(offsetX: number, projectionCache: { [_: number]: Point; }, - rotateToLine: boolean) { + rotateToLine: boolean, + getElevation: (x: number, y: number) => number) { const combinedOffsetX = flip ? offsetX - lineOffsetX : @@ -390,7 +405,7 @@ function placeGlyphAlongLine(offsetX: number, current = projectionCache[currentIndex]; if (current === undefined) { const currentVertex = new Point(lineVertexArray.getx(currentIndex), lineVertexArray.gety(currentIndex)); - const projection = project(currentVertex, labelPlaneMatrix); + const projection = project(currentVertex, labelPlaneMatrix, getElevation); if (projection.signedDistanceFromCamera > 0) { current = projectionCache[currentIndex] = projection.point; } else { @@ -401,7 +416,7 @@ function placeGlyphAlongLine(offsetX: number, tileAnchorPoint : new Point(lineVertexArray.getx(previousLineVertexIndex), lineVertexArray.gety(previousLineVertexIndex)); // Don't cache because the new vertex might not be far enough out for future glyphs on the same segment - current = projectTruncatedLineSegment(previousTilePoint, currentVertex, prev, absOffsetX - distanceToPrev + 1, labelPlaneMatrix); + current = projectTruncatedLineSegment(previousTilePoint, currentVertex, prev, absOffsetX - distanceToPrev + 1, labelPlaneMatrix, getElevation); } } diff --git a/src/ui/camera.ts b/src/ui/camera.ts index fa40c0eed21..97a4214bfbf 100644 --- a/src/ui/camera.ts +++ b/src/ui/camera.ts @@ -898,6 +898,7 @@ abstract class Camera extends Evented { _prepareEase(eventData: any, noMoveStart: boolean, currently: any = {}) { this._moving = true; + this.fire(new Event('freezeElevation', {freeze: true})); if (!noMoveStart && !currently.moving) { this.fire(new Event('movestart', eventData)); @@ -933,6 +934,7 @@ abstract class Camera extends Evented { return; } delete this._easeId; + this.fire(new Event('freezeElevation', {freeze: false})); const wasZooming = this._zooming; const wasRotating = this._rotating; diff --git a/src/ui/control/terrain_control.ts b/src/ui/control/terrain_control.ts new file mode 100644 index 00000000000..e819357f725 --- /dev/null +++ b/src/ui/control/terrain_control.ts @@ -0,0 +1,77 @@ +import DOM from '../../util/dom'; +import {bindAll} from '../../util/util'; + +import type Map from '../map'; +import type {IControl} from './control'; +import type {TerrainSpecification} from '../../style-spec/types.g'; + +/** + * An `TerrainControl` control adds a button to turn terrain on and off. + * + * @implements {IControl} + * @param {Object} [options] + * @param {string} [options.id] The ID of the raster-dem source to use. + * @param {exaggeration: number; elevationOffset: number} [options.options] Allowed options are exaggeration: number; elevationOffset: number + * @example + * var map = new maplibregl.Map({TerrainControl: false}) + * .addControl(new maplibregl.TerrainControl({ + * source: "terrain" + * })); + */ +class TerrainControl implements IControl { + options: TerrainSpecification; + _map: Map; + _container: HTMLElement; + _terrainButton: HTMLButtonElement; + + constructor(options: TerrainSpecification) { + this.options = options; + + bindAll([ + '_toggleTerrain', + '_updateTerrainIcon', + ], this); + } + + onAdd(map: Map) { + this._map = map; + this._container = DOM.create('div', 'maplibregl-ctrl maplibregl-ctrl-group mapboxgl-ctrl mapboxgl-ctrl-group'); + this._terrainButton = DOM.create('button', 'maplibregl-ctrl-terrain mapboxgl-ctrl-terrain', this._container); + DOM.create('span', 'maplibregl-ctrl-icon mapboxgl-ctrl-icon', this._terrainButton).setAttribute('aria-hidden', 'true'); + this._terrainButton.type = 'button'; + this._terrainButton.addEventListener('click', this._toggleTerrain); + + this._updateTerrainIcon(); + this._map.on('terrain', this._updateTerrainIcon); + return this._container; + } + + onRemove() { + DOM.remove(this._container); + this._map.off('terrain', this._updateTerrainIcon); + this._map = undefined; + } + + _toggleTerrain() { + if (this._map.getTerrain()) { + this._map.setTerrain(null); + } else { + this._map.setTerrain(this.options); + } + this._updateTerrainIcon(); + } + + _updateTerrainIcon() { + this._terrainButton.classList.remove('maplibregl-ctrl-terrain', 'mapboxgl-ctrl-terrain'); + this._terrainButton.classList.remove('maplibregl-ctrl-terrain-enabled', 'mapboxgl-ctrl-terrain-enabled'); + if (this._map.style.terrain) { + this._terrainButton.classList.add('maplibregl-ctrl-terrain-enabled', 'mapboxgl-ctrl-terrain-enabled'); + this._terrainButton.title = this._map._getUIString('TerrainControl.disableTerrain'); + } else { + this._terrainButton.classList.add('maplibregl-ctrl-terrain', 'mapboxgl-ctrl-terrain'); + this._terrainButton.title = this._map._getUIString('TerrainControl.enableTerrain'); + } + } +} + +export default TerrainControl; diff --git a/src/ui/default_locale.ts b/src/ui/default_locale.ts index 3c29809a922..a3ea2bc0176 100644 --- a/src/ui/default_locale.ts +++ b/src/ui/default_locale.ts @@ -13,8 +13,9 @@ const defaultLocale = { 'ScaleControl.Meters': 'm', 'ScaleControl.Kilometers': 'km', 'ScaleControl.Miles': 'mi', - 'ScaleControl.NauticalMiles': 'nm' - + 'ScaleControl.NauticalMiles': 'nm', + 'TerrainControl.enableTerrain': 'Enable terrain', + 'TerrainControl.disableTerrain': 'Disable terrain' }; export default defaultLocale; diff --git a/src/ui/events.ts b/src/ui/events.ts index 2f785279edc..41b33c7dee5 100644 --- a/src/ui/events.ts +++ b/src/ui/events.ts @@ -311,6 +311,10 @@ export type MapDataEvent = { sourceDataType: MapSourceDataType; }; +export type MapTerrainEvent = { + type: 'terrain'; +}; + export type MapContextEvent = { type: 'webglcontextlost' | 'webglcontextrestored'; originalEvent: WebGLContextEvent; @@ -386,6 +390,8 @@ export type MapEventType = { pitchend: MapLibreEvent; wheel: MapWheelEvent; + + terrain: MapTerrainEvent; }; export type MapEvent = @@ -1395,6 +1401,15 @@ export type MapEvent = | 'style.load' /** + * @event terrain + * @memberof Map + * @instance + * @private + */ + | 'terrain' + + /** + * Fired when a request for one of the map's sources' tiles is aborted. * Fired when a request for one of the map's sources' data is aborted. * See {@link MapDataEvent} for more information. * diff --git a/src/ui/handler_manager.ts b/src/ui/handler_manager.ts index 027f85f977a..9ffa6b7260e 100644 --- a/src/ui/handler_manager.ts +++ b/src/ui/handler_manager.ts @@ -18,6 +18,7 @@ import DragRotateHandler from './handler/shim/drag_rotate'; import TouchZoomRotateHandler from './handler/shim/touch_zoom_rotate'; import {bindAll, extend} from '../util/util'; import Point from '@mapbox/point-geometry'; +import LngLat from '../geo/lng_lat'; import assert from 'assert'; export type InputEvent = MouseEvent | TouchEvent | KeyboardEvent | WheelEvent; @@ -100,6 +101,7 @@ class HandlerManager { _handlersById: {[x: string]: Handler}; _updatingCamera: boolean; _changes: Array<[HandlerResult, any, any]>; + _drag: {center: Point; lngLat: LngLat; point: Point; handlerName: string}; _previousActiveHandlers: {[x: string]: Handler}; _listeners: Array<[Window | Document | HTMLElement, string, { passive?: boolean; @@ -411,11 +413,11 @@ class HandlerManager { } _updateMapTransform(combinedResult: any, combinedEventsInProgress: any, deactivatedHandlers: any) { - const map = this._map; const tr = map.transform; + const terrain = map.style && map.style.terrain; - if (!hasChange(combinedResult)) { + if (!hasChange(combinedResult) && !(terrain && this._drag)) { return this._fireEvents(combinedEventsInProgress, deactivatedHandlers, true); } @@ -433,7 +435,35 @@ class HandlerManager { if (bearingDelta) tr.bearing += bearingDelta; if (pitchDelta) tr.pitch += pitchDelta; if (zoomDelta) tr.zoom += zoomDelta; - tr.setLocationAtPoint(loc, around); + + if (!terrain) { + tr.setLocationAtPoint(loc, around); + } else { + // when 3d-terrain is enabled act a litte different: + // - draging 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 + // the horizon to the center in one move. + // So this logic avoids the problem, that in such cases you easily loose orientation. + // - scrollzoom does not zoom into the mouse-point, instead it zooms into map-center + // this should be fixed in future-version + // when dragging starts, remember mousedown-location and panDelta from this point + if (combinedEventsInProgress.drag && !this._drag) { + this._drag = { + center: tr.centerPoint, + lngLat: tr.pointLocation(around), + point: around, + handlerName: combinedEventsInProgress.drag.handlerName + }; + map.fire(new Event('freezeElevation', {freeze: true})); + // when dragging ends, recalcuate the zoomlevel for the new center coordinate + } else if (this._drag && deactivatedHandlers[this._drag.handlerName]) { + map.fire(new Event('freezeElevation', {freeze: false})); + this._drag = null; + // drag map + } else if (combinedEventsInProgress.drag && this._drag) { + tr.center = tr.pointLocation(tr.centerPoint.sub(panDelta)); + } + } this._map._update(); if (!combinedResult.noInertia) this._inertia.record(combinedResult); diff --git a/src/ui/map.ts b/src/ui/map.ts index ada8f3b4b84..ff941768468 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -51,7 +51,8 @@ import type { FilterSpecification, StyleSpecification, LightSpecification, - SourceSpecification + SourceSpecification, + TerrainSpecification } from '../style-spec/types.g'; import {Callback} from '../types/callback'; import type {ControlPosition, IControl} from './control/control'; @@ -432,6 +433,10 @@ class Map extends Camera { this.on('move', () => this._update(false)); this.on('moveend', () => this._update(false)); this.on('zoom', () => this._update(true)); + this.on('terrain', () => { + this.painter.terrainFacilitator.dirty = true; + this._update(true); + }); if (typeof window !== 'undefined') { addEventListener('online', this._onWindowOnline, false); @@ -873,7 +878,7 @@ class Map extends Camera { * var point = map.project(coordinate); */ project(lnglat: LngLatLike) { - return this.transform.locationPoint(LngLat.convert(lnglat)); + return this.transform.locationPoint(LngLat.convert(lnglat), this.style && this.style.terrain); } /** @@ -889,7 +894,7 @@ class Map extends Camera { * }); */ unproject(point: PointLike) { - return this.transform.pointLocation(Point.convert(point)); + return this.transform.pointLocation(Point.convert(point), this.style && this.style.terrain); } /** @@ -1571,6 +1576,28 @@ class Map extends Camera { return source.loaded(); } + /** + * Loads a 3D terrain mesh, based on a "raster-dem" source. + * @param {TerrainSpecification} [options] Options object. + * @returns {Map} `this` + * @example + * map.setTerrain({ source: 'terrain' }); + */ + setTerrain(options: TerrainSpecification): Map { + this.style.setTerrain(options); + return this; + } + + /** + * Get the terrain-options if terrain is loaded + * @returns {TerrainSpecification} the TerrainSpecification passed to setTerrain + * @example + * map.getTerrain(); // { source: 'terrain' }; + */ + getTerrain(): TerrainSpecification { + return this.style.terrain && this.style.terrain.options; + } + /** * Returns a Boolean indicating whether all tiles in the viewport from all sources on * the style are loaded. @@ -1579,8 +1606,7 @@ class Map extends Camera { * @example * var tilesLoaded = map.areTilesLoaded(); */ - - areTilesLoaded() { + areTilesLoaded(): boolean { const sources = this.style && this.style.sourceCaches; for (const id in sources) { const source = sources[id]; @@ -1614,7 +1640,7 @@ class Map extends Camera { * @example * map.removeSource('bathymetry-data'); */ - removeSource(id: string) { + removeSource(id: string): Map { this.style.removeSource(id); return this._update(true); } @@ -2550,6 +2576,10 @@ class Map extends Camera { this.style._updateSources(this.transform); } + // update terrain stuff + if (this.style.terrain) this.style.terrain.sourceCache.update(this.transform, this.style.terrain); + this.transform.updateElevation(this.style.terrain); + this._placementDirty = this.style && this.style._updatePlacement(this.painter.transform, this.showCollisionBoxes, this._fadeDuration, this._crossSourceCollisions); // Actually draw diff --git a/src/ui/marker.test.ts b/src/ui/marker.test.ts index 46c2a0f60b6..62aebe1a39f 100644 --- a/src/ui/marker.test.ts +++ b/src/ui/marker.test.ts @@ -4,6 +4,7 @@ import Popup from './popup'; import LngLat from '../geo/lng_lat'; import Point from '@mapbox/point-geometry'; import simulate from '../../test/unit/lib/simulate_interaction'; +import type Terrain from '../render/terrain'; function createMap(options = {}) { const container = window.document.createElement('div'); @@ -771,4 +772,24 @@ describe('marker', () => { map.remove(); }); + + test('Marker removed after update when terrain is on should clear timeout', () => { + jest.spyOn(global, 'setTimeout'); + jest.spyOn(global, 'clearTimeout'); + const map = createMap(); + const marker = new Marker() + .setLngLat([0, 0]) + .addTo(map); + map.style.terrain = { + getElevation: () => 0 + } as any as Terrain; + + marker.setOffset([10, 10]); + + expect(setTimeout).toHaveBeenCalled(); + marker.remove(); + expect(clearTimeout).toHaveBeenCalled(); + + map.remove(); + }); }); diff --git a/src/ui/marker.ts b/src/ui/marker.ts index f615512e3af..333cd7a4c69 100644 --- a/src/ui/marker.ts +++ b/src/ui/marker.ts @@ -74,6 +74,7 @@ export default class Marker extends Evented { _pitchAlignment: string; _rotationAlignment: string; _originalTabIndex: string; // original tabindex of _element + _opacityTimeout: ReturnType; constructor(options?: MarkerOptions, legacyOptions?: MarkerOptions) { super(); @@ -264,6 +265,10 @@ export default class Marker extends Evented { * @returns {Marker} `this` */ remove() { + if (this._opacityTimeout) { + clearTimeout(this._opacityTimeout); + delete this._opacityTimeout; + } if (this._map) { this._map.off('click', this._onMapClick); this._map.off('move', this._update); @@ -469,6 +474,15 @@ export default class Marker extends Evented { } DOM.setTransform(this._element, `${anchorTranslate[this._anchor]} translate(${this._pos.x}px, ${this._pos.y}px) ${pitch} ${rotation}`); + + // in case of 3D, ask the terrain coords-framebuffer for this pos and check if the marker is visible + // call this logic in setTimeout with a timeout of 100ms to save performance in map-movement + if (this._map.style && this._map.style.terrain && !this._opacityTimeout) this._opacityTimeout = setTimeout(() => { + const lnglat = this._map.unproject(this._pos); + const metresPerPixel = 40075016.686 * Math.abs(Math.cos(this._lngLat.lat * Math.PI / 180)) / Math.pow(2, this._map.transform.tileZoom + 8); + this._element.style.opacity = lnglat.distanceTo(this._lngLat) > metresPerPixel * 20 ? '0.2' : '1.0'; + this._opacityTimeout = null; + }, 100); } /** diff --git a/src/util/primitives.ts b/src/util/primitives.ts index 6af9a8f3200..44a4bab31c2 100644 --- a/src/util/primitives.ts +++ b/src/util/primitives.ts @@ -1,5 +1,4 @@ import {mat4, vec3, vec4} from 'gl-matrix'; -import assert from 'assert'; class Frustum { @@ -19,10 +18,12 @@ class Frustum { const scale = Math.pow(2, zoom); - // Transform frustum corner points from clip space to tile space - const frustumCoords = clipSpaceCorners - .map(v => vec4.transformMat4([] as any, v as any, invProj)) - .map(v => vec4.scale([] as any, v, 1.0 / v[3] / worldSize * scale)); + // Transform frustum corner points from clip space to tile space, Z to meters + const frustumCoords = clipSpaceCorners.map(v => { + v = vec4.transformMat4([] as any, v as any, invProj) as any; + const s = 1.0 / v[3] / worldSize * scale; + return vec4.mul(v as any, v as any, [s, s, 1.0 / v[3], s] as vec4); + }); const frustumPlanePointIndices = [ [0, 1, 2], // near @@ -84,14 +85,16 @@ class Aabb { intersects(frustum: Frustum): number { // Execute separating axis test between two convex objects to find intersections // Each frustum plane together with 3 major axes define the separating axes - // Note: test only 4 points as both min and max points have equal elevation - assert(this.min[2] === 0 && this.max[2] === 0); const aabbPoints = [ - [this.min[0], this.min[1], 0.0, 1], - [this.max[0], this.min[1], 0.0, 1], - [this.max[0], this.max[1], 0.0, 1], - [this.min[0], this.max[1], 0.0, 1] + [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 fullyInside = true; diff --git a/test/integration/render/render.test.ts b/test/integration/render/render.test.ts index 700cca07fa5..f82acb44865 100644 --- a/test/integration/render/render.test.ts +++ b/test/integration/render/render.test.ts @@ -63,6 +63,7 @@ type TestData = { queryGeometry: PointLike; queryOptions: any; error: Error; + maxPitch: number; // base64-encoded content of the PNG results actual: string; @@ -419,6 +420,7 @@ function getImageFromStyle(style: StyleWithTestData): Promise { classes: options.classes, interactive: false, attributionControl: false, + maxPitch: options.maxPitch, pixelRatio: options.pixelRatio, preserveDrawingBuffer: true, axonometric: options.axonometric || false, diff --git a/test/integration/render/tests/debug/collision-horizon-cutoff/expected.png b/test/integration/render/tests/debug/collision-horizon-cutoff/expected.png new file mode 100644 index 00000000000..73ffb2d58b4 Binary files /dev/null and b/test/integration/render/tests/debug/collision-horizon-cutoff/expected.png differ diff --git a/test/integration/render/tests/debug/collision-horizon-cutoff/style.json b/test/integration/render/tests/debug/collision-horizon-cutoff/style.json new file mode 100644 index 00000000000..9b9b6f256f7 --- /dev/null +++ b/test/integration/render/tests/debug/collision-horizon-cutoff/style.json @@ -0,0 +1,49 @@ +{ + "version": 8, + "metadata": { + "test": { + "collisionDebug": true, + "width": 500, + "height": 500, + "allowed": 0.005, + "maxPitch": 85 + } + }, + "center": [ + -60, + 0 + ], + "zoom": 5, + "pitch": 75, + "bearing": 0, + "sources": { + "geojson": { + "type": "geojson", + "data": "local://data/places.geojson" + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "sprite": "local://sprites/sprite", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "text-field": "test test test", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] + } diff --git a/test/integration/render/tests/debug/collision-icon-text-point-translate/expected.png b/test/integration/render/tests/debug/collision-icon-text-point-translate/expected.png index bb2a67c4964..4786207dedd 100644 Binary files a/test/integration/render/tests/debug/collision-icon-text-point-translate/expected.png and b/test/integration/render/tests/debug/collision-icon-text-point-translate/expected.png differ diff --git a/test/integration/render/tests/debug/collision-pitched-wrapped/expected.png b/test/integration/render/tests/debug/collision-pitched-wrapped/expected.png index d23243aa8ae..6d3c1d4678b 100644 Binary files a/test/integration/render/tests/debug/collision-pitched-wrapped/expected.png and b/test/integration/render/tests/debug/collision-pitched-wrapped/expected.png differ diff --git a/test/integration/render/tests/debug/collision-pitched/expected.png b/test/integration/render/tests/debug/collision-pitched/expected.png index d1eb3bf6a38..45e8ce2982b 100644 Binary files a/test/integration/render/tests/debug/collision-pitched/expected.png and b/test/integration/render/tests/debug/collision-pitched/expected.png differ diff --git a/test/integration/render/tests/debug/collision/expected.png b/test/integration/render/tests/debug/collision/expected.png index 6dc313d9531..8e5b6b994d8 100644 Binary files a/test/integration/render/tests/debug/collision/expected.png and b/test/integration/render/tests/debug/collision/expected.png differ