diff --git a/src/geo/transform.js b/src/geo/transform.js index 25fe5c948a8..5d67f83cd7a 100644 --- a/src/geo/transform.js +++ b/src/geo/transform.js @@ -6,12 +6,11 @@ import MercatorCoordinate, {mercatorXfromLng, mercatorYfromLat, mercatorZfromAlt import Point from '@mapbox/point-geometry'; import {wrap, clamp} from '../util/util'; import {number as interpolate} from '../style-spec/util/interpolate'; -import tileCover from '../util/tile_cover'; -import {UnwrappedTileID} from '../source/tile_id'; import EXTENT from '../data/extent'; -import {vec4, mat4, mat2} from 'gl-matrix'; +import {vec4, mat4, mat2, vec2} from 'gl-matrix'; +import {Aabb, Frustum} from '../util/primitives.js'; -import type {OverscaledTileID, CanonicalTileID} from '../source/tile_id'; +import {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../source/tile_id'; /** * A single transform, generally used for a single tile to be @@ -34,6 +33,7 @@ class Transform { cameraToCenterDistance: number; mercatorMatrix: Array; projMatrix: Float64Array; + invProjMatrix: Float64Array; alignedProjMatrix: Float64Array; pixelMatrix: Float64Array; pixelMatrixInverse: Float64Array; @@ -274,17 +274,85 @@ class Transform { if (options.minzoom !== undefined && z < options.minzoom) return []; if (options.maxzoom !== undefined && z > options.maxzoom) z = options.maxzoom; + const minZoom = options.minzoom || 0; const centerCoord = MercatorCoordinate.fromLngLat(this.center); const numTiles = Math.pow(2, z); const centerPoint = new Point(numTiles * centerCoord.x - 0.5, numTiles * centerCoord.y - 0.5); - const cornerCoords = [ - this.pointCoordinate(new Point(0, 0)), - this.pointCoordinate(new Point(this.width, 0)), - this.pointCoordinate(new Point(this.width, this.height)), - this.pointCoordinate(new Point(0, this.height)) - ]; - return tileCover(z, cornerCoords, options.reparseOverscaled ? actualZ : z, this._renderWorldCopies) - .sort((a, b) => centerPoint.dist(a.canonical) - centerPoint.dist(b.canonical)); + const cameraFrustum = Frustum.fromInvProjectionMatrix(this.invProjMatrix, this.worldSize, z); + + // There should always be a certain number of maximum zoom level tiles surrounding the center location + const radiusOfMaxLvlLodInTiles = 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, + y: 0, + wrap, + fullyVisible: false + }; + }; + + // Do a depth-first traversal to find visible tiles and proper levels of detail + const stack = []; + const result = []; + const maxZoom = z; + const zOffset = (options.reparseOverscaled ? actualZ : z) - z; + + if (this._renderWorldCopies) { + // Render copy of the globe thrice on both sides + for (let i = 1; i <= 3; i++) { + stack.push(newRootTile(-i)); + stack.push(newRootTile(i)); + } + } + + stack.push(newRootTile(0)); + + // Stream position will determine the "center of the streaming", + // ie. where the most detailed tiles are loaded. + const streamPos = [centerPoint.x, centerPoint.y, 0]; + + while (stack.length > 0) { + const it = stack.pop(); + const x = it.x; + const y = it.y; + let fullyVisible = it.fullyVisible; + + // Visibility of a tile is not required if any of its ancestor if fully inside the frustum + if (!fullyVisible) { + const intersectResult = it.aabb.intersects(cameraFrustum); + + if (intersectResult === 'none') + continue; + + fullyVisible = intersectResult === 'contains'; + } + + const distanceXY = it.aabb.distanceXY(streamPos); + const longestDim = Math.max(Math.abs(distanceXY[0]), Math.abs(distanceXY[1])); + const distToSplit = radiusOfMaxLvlLodInTiles + (1 << (maxZoom - it.zoom)) - 2; + + // Have we reached the target depth or is the tile too far away to be any split further? + if (it.zoom === maxZoom || (longestDim > distToSplit && it.zoom >= minZoom)) { + result.push({ + tileID: new OverscaledTileID(it.zoom + zOffset, it.wrap, it.zoom, x, y), + distanceSq: vec2.dot(distanceXY, distanceXY) + }); + continue; + } + + for (let i = 0; i < 4; i++) { + const childX = (x << 1) + (i % 2); + const childY = (y << 1) + Math.floor(i / 2); + + stack.push({aabb: it.aabb.quadrant(i), zoom: it.zoom + 1, x: childX, y: childY, wrap: it.wrap, fullyVisible}); + } + } + + return result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID); } resize(width: number, height: number) { @@ -547,7 +615,7 @@ class Transform { // (the distance between[width/2, height/2] and [width/2 + 1, height/2]) const halfFov = this._fov / 2; const groundAngle = Math.PI / 2 + this._pitch; - const topHalfSurfaceDistance = Math.sin(halfFov) * this.cameraToCenterDistance / Math.sin(Math.PI - groundAngle - halfFov); + const topHalfSurfaceDistance = Math.sin(halfFov) * this.cameraToCenterDistance / Math.sin(clamp(Math.PI - groundAngle - halfFov, 0.01, Math.PI - 0.01)); const point = this.point; const x = point.x, y = point.y; @@ -583,6 +651,7 @@ class Transform { mat4.scale(m, m, [1, 1, mercatorZfromAltitude(1, this.center.lat) * this.worldSize, 1]); this.projMatrix = m; + this.invProjMatrix = mat4.invert([], this.projMatrix); // 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 diff --git a/src/source/source_cache.js b/src/source/source_cache.js index d602cec1eb7..2a511218b5c 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -480,7 +480,6 @@ class SourceCache extends Evented { roundZoom: this._source.roundZoom, reparseOverscaled: this._source.reparseOverscaled }); - if (this._source.hasTile) { idealTileIDs = idealTileIDs.filter((coord) => (this._source.hasTile: any)(coord)); } diff --git a/src/util/primitives.js b/src/util/primitives.js new file mode 100644 index 00000000000..58cfc68f180 --- /dev/null +++ b/src/util/primitives.js @@ -0,0 +1,148 @@ +// @flow + +import {vec3, vec4} from 'gl-matrix'; +import assert from 'assert'; + +type IntersectResult = 'none' | 'intersects' | 'contains'; + +class Frustum { + points: Array>; + planes: Array>; + + constructor(points_: Array>, planes_: Array>) { + this.points = points_; + this.planes = planes_; + } + + static fromInvProjectionMatrix(invProj: Float64Array, worldSize: number, zoom: number): Frustum { + const clipSpaceCorners = [ + [-1, 1, -1, 1], + [ 1, 1, -1, 1], + [ 1, -1, -1, 1], + [-1, -1, -1, 1], + [-1, 1, 1, 1], + [ 1, 1, 1, 1], + [ 1, -1, 1, 1], + [-1, -1, 1, 1] + ]; + + const scale = Math.pow(2, zoom); + + // Transform frustum corner points from clip space to tile space + const frustumCoords = clipSpaceCorners + .map(v => vec4.transformMat4([], v, invProj)) + .map(v => vec4.scale([], v, 1.0 / v[3] / worldSize * scale)); + + const frustumPlanePointIndices = [ + [0, 1, 2], // near + [6, 5, 4], // far + [0, 3, 7], // left + [2, 1, 5], // right + [3, 2, 6], // bottom + [0, 4, 5] // top + ]; + + const frustumPlanes = frustumPlanePointIndices.map((p: Array) => { + const a = vec3.sub([], frustumCoords[p[0]], frustumCoords[p[1]]); + const b = vec3.sub([], frustumCoords[p[2]], frustumCoords[p[1]]); + const n = vec3.normalize([], vec3.cross([], a, b)); + const d = -vec3.dot(n, frustumCoords[p[1]]); + return n.concat(d); + }); + + return new Frustum(frustumCoords, frustumPlanes); + } +} + +class Aabb { + min: vec3; + max: vec3; + center: vec3; + + constructor(min_: vec3, max_: vec3) { + this.min = min_; + this.max = max_; + this.center = vec3.scale([], vec3.add([], this.min, this.max), 0.5); + } + + quadrant(index: number): Aabb { + const split = [(index % 2) === 0, index < 2]; + const qMin = vec3.clone(this.min); + const qMax = vec3.clone(this.max); + for (let axis = 0; axis < split.length; axis++) { + qMin[axis] = split[axis] ? this.min[axis] : this.center[axis]; + qMax[axis] = split[axis] ? this.center[axis] : this.max[axis]; + } + // Elevation is always constant, hence quadrant.max.z = this.max.z + qMax[2] = this.max[2]; + return new Aabb(qMin, qMax); + } + + closestPoint(point: Array): Array { + const x = Math.max(Math.min(this.max[0], point[0]), this.min[0]); + const y = Math.max(Math.min(this.max[1], point[1]), this.min[1]); + return [x, y]; + } + + distanceXY(point: Array): Array { + const aabbPoint = this.closestPoint(point); + const dx = aabbPoint[0] - point[0]; + const dy = aabbPoint[1] - point[1]; + return [dx, dy]; + } + + intersects(frustum: Frustum): IntersectResult { + // 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] + ]; + + let fullyInside = true; + + for (let p = 0; p < frustum.planes.length; p++) { + const plane = frustum.planes[p]; + let pointsInside = 0; + + for (let i = 0; i < aabbPoints.length; i++) { + pointsInside += vec4.dot(plane, aabbPoints[i]) >= 0; + } + + if (pointsInside === 0) + return 'none'; + + if (pointsInside !== aabbPoints.length) + fullyInside = false; + } + + if (fullyInside) + return 'contains'; + + for (let axis = 0; axis < 3; axis++) { + let projMin = Number.MAX_VALUE; + let projMax = -Number.MAX_VALUE; + + for (let p = 0; p < frustum.points.length; p++) { + const projectedPoint = frustum.points[p][axis] - this.min[axis]; + + projMin = Math.min(projMin, projectedPoint); + projMax = Math.max(projMax, projectedPoint); + } + + if (projMax < 0 || projMin > this.max[axis] - this.min[axis]) + return 'none'; + } + + return 'intersects'; + } +} +export { + Aabb, + Frustum +}; diff --git a/src/util/tile_cover.js b/src/util/tile_cover.js deleted file mode 100644 index 43fe4e03a70..00000000000 --- a/src/util/tile_cover.js +++ /dev/null @@ -1,100 +0,0 @@ -// @flow - -import MercatorCoordinate from '../geo/mercator_coordinate'; -import Point from '@mapbox/point-geometry'; - -import {OverscaledTileID} from '../source/tile_id'; - -export default tileCover; - -function tileCover(z: number, bounds: [MercatorCoordinate, MercatorCoordinate, MercatorCoordinate, MercatorCoordinate], - actualZ: number, renderWorldCopies: boolean | void): Array { - if (renderWorldCopies === undefined) { - renderWorldCopies = true; - } - const tiles = 1 << z; - const t = {}; - - function scanLine(x0, x1, y) { - let x, w, wx, coord; - if (y >= 0 && y <= tiles) { - for (x = x0; x < x1; x++) { - w = Math.floor(x / tiles); - wx = (x % tiles + tiles) % tiles; - if (w === 0 || renderWorldCopies === true) { - coord = new OverscaledTileID(actualZ, w, z, wx, y); - t[coord.key] = coord; - } - } - } - } - - const zoomedBounds = bounds.map((coord) => new Point(coord.x, coord.y)._mult(tiles)); - - // Divide the screen up in two triangles and scan each of them: - // +---/ - // | / | - // /---+ - scanTriangle(zoomedBounds[0], zoomedBounds[1], zoomedBounds[2], 0, tiles, scanLine); - scanTriangle(zoomedBounds[2], zoomedBounds[3], zoomedBounds[0], 0, tiles, scanLine); - - return Object.keys(t).map((id) => { - return t[id]; - }); -} - -// Taken from polymaps src/Layer.js -// https://github.com/simplegeo/polymaps/blob/master/src/Layer.js#L333-L383 - -function edge(a: Point, b: Point) { - if (a.y > b.y) { const t = a; a = b; b = t; } - return { - x0: a.x, - y0: a.y, - x1: b.x, - y1: b.y, - dx: b.x - a.x, - dy: b.y - a.y - }; -} - -function scanSpans(e0, e1, ymin, ymax, scanLine) { - const y0 = Math.max(ymin, Math.floor(e1.y0)); - const y1 = Math.min(ymax, Math.ceil(e1.y1)); - - // sort edges by x-coordinate - if ((e0.x0 === e1.x0 && e0.y0 === e1.y0) ? - (e0.x0 + e1.dy / e0.dy * e0.dx < e1.x1) : - (e0.x1 - e1.dy / e0.dy * e0.dx < e1.x0)) { - const t = e0; e0 = e1; e1 = t; - } - - // scan lines! - const m0 = e0.dx / e0.dy; - const m1 = e1.dx / e1.dy; - const d0 = e0.dx > 0; // use y + 1 to compute x0 - const d1 = e1.dx < 0; // use y + 1 to compute x1 - for (let y = y0; y < y1; y++) { - const x0 = m0 * Math.max(0, Math.min(e0.dy, y + d0 - e0.y0)) + e0.x0; - const x1 = m1 * Math.max(0, Math.min(e1.dy, y + d1 - e1.y0)) + e1.x0; - scanLine(Math.floor(x1), Math.ceil(x0), y); - } -} - -function scanTriangle(a: Point, b: Point, c: Point, ymin, ymax, scanLine) { - let ab = edge(a, b), - bc = edge(b, c), - ca = edge(c, a); - - let t; - - // sort edges by y-length - if (ab.dy > bc.dy) { t = ab; ab = bc; bc = t; } - if (ab.dy > ca.dy) { t = ab; ab = ca; ca = t; } - if (bc.dy > ca.dy) { t = bc; bc = ca; ca = t; } - - // scan span! scan span! - if (ab.dy) scanSpans(ca, ab, ymin, ymax, scanLine); - if (bc.dy) scanSpans(ca, bc, ymin, ymax, scanLine); -} - diff --git a/test/unit/util/tile_cover.test.js b/test/unit/util/tile_cover.test.js deleted file mode 100644 index 27cf8370f47..00000000000 --- a/test/unit/util/tile_cover.test.js +++ /dev/null @@ -1,95 +0,0 @@ -import {test} from '../../util/test'; -import tileCover from '../../../src/util/tile_cover'; -import MercatorCoordinate from '../../../src/geo/mercator_coordinate'; -import {OverscaledTileID} from '../../../src/source/tile_id'; - -test('tileCover', (t) => { - - t.test('.cover', (t) => { - t.test('calculates tile coverage at w = 0', (t) => { - const z = 2, - coords = [ - new MercatorCoordinate(0, 0.25), - new MercatorCoordinate(0.25, 0.25), - new MercatorCoordinate(0.25, 0.5), - new MercatorCoordinate(0, 0.5) - ], - res = tileCover(z, coords, z); - t.deepEqual(res, [new OverscaledTileID(2, 0, 2, 0, 1)]); - t.end(); - }); - - t.test('calculates tile coverage at w > 0', (t) => { - const z = 2, - coords = [ - new MercatorCoordinate(3, 0.25), - new MercatorCoordinate(3.25, 0.25), - new MercatorCoordinate(3.25, 0.5), - new MercatorCoordinate(3, 0.5) - ], - res = tileCover(z, coords, z); - t.deepEqual(res, [new OverscaledTileID(2, 3, 2, 0, 1)]); - t.end(); - }); - - t.test('calculates tile coverage at w = -1', (t) => { - const z = 2, - coords = [ - new MercatorCoordinate(-0.25, 0.25), - new MercatorCoordinate(0, 0.25), - new MercatorCoordinate(0, 0.5), - new MercatorCoordinate(-0.25, 0.5) - ], - res = tileCover(z, coords, z); - t.deepEqual(res, [new OverscaledTileID(2, -1, 2, 3, 1)]); - t.end(); - }); - - t.test('calculates tile coverage at w < -1', (t) => { - const z = 2, - coords = [ - new MercatorCoordinate(-3.25, 0.25), - new MercatorCoordinate(-3, 0.25), - new MercatorCoordinate(-3, 0.5), - new MercatorCoordinate(-3.25, 0.5) - ], - res = tileCover(z, coords, z); - t.deepEqual(res, [new OverscaledTileID(2, -4, 2, 3, 1)]); - t.end(); - }); - - t.test('calculates tile coverage across meridian', (t) => { - const z = 2, - coords = [ - new MercatorCoordinate(-0.125, 0.25), - new MercatorCoordinate(0.125, 0.25), - new MercatorCoordinate(0.125, 0.5), - new MercatorCoordinate(-0.125, 0.5) - ], - res = tileCover(z, coords, z); - t.deepEqual(res, [ - new OverscaledTileID(2, 0, 2, 0, 1), - new OverscaledTileID(2, -1, 2, 3, 1)]); - t.end(); - }); - - t.test('only includes tiles for a single world, if renderWorldCopies is set to false', (t) => { - const z = 2, - coords = [ - new MercatorCoordinate(-0.125, 0.25), - new MercatorCoordinate(0.125, 0.25), - new MercatorCoordinate(0.125, 0.5), - new MercatorCoordinate(-0.125, 0.5) - ], - renderWorldCopies = false, - res = tileCover(z, coords, z, renderWorldCopies); - t.deepEqual(res, [ - new OverscaledTileID(2, 0, 2, 0, 1)]); - t.end(); - }); - - t.end(); - }); - - t.end(); -});