From f0f94170397848fb69d3bf18c37000e34e95e390 Mon Sep 17 00:00:00 2001 From: Mikko Pulkki Date: Tue, 12 Nov 2019 18:45:30 +0200 Subject: [PATCH 1/4] LOD support for tile coverage --- src/geo/transform.js | 100 +++++++++++++++++--- src/source/source_cache.js | 1 - src/util/primitives.js | 148 ++++++++++++++++++++++++++++++ src/util/tile_cover.js | 100 -------------------- test/unit/util/tile_cover.test.js | 95 ------------------- 5 files changed, 234 insertions(+), 210 deletions(-) create mode 100644 src/util/primitives.js delete mode 100644 src/util/tile_cover.js delete mode 100644 test/unit/util/tile_cover.test.js diff --git a/src/geo/transform.js b/src/geo/transform.js index f80dc279277..10dec4243d0 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; @@ -276,17 +276,88 @@ class Transform { if (options.minzoom !== undefined && z < options.minzoom) return []; if (options.maxzoom !== undefined && z > options.maxzoom) z = options.maxzoom; + let 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 centerPoint = new Point(numTiles * centerCoord.x, numTiles * centerCoord.y); + const cameraFrustum = Frustum.fromInvProjectionMatrix(this.invProjMatrix, this.worldSize, z); + + if (this.pitch <= 60.0) + minZoom = 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 overscaledZ = options.reparseOverscaled ? actualZ : z; + + if (this._renderWorldCopies) { + // Render copy of the globe thrice on both sides + for (let i = 1; i <= 3; i++) { + stack.push(newRootTile(-i)); + stack.push(newRootTile(i)); + } + } + + stack.push(newRootTile(0)); + + // 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 === maxZoom ? overscaledZ : it.zoom, it.wrap, it.zoom, x, y), + distanceSq: vec2.sqrLen([streamPos[0] - 0.5 - x, streamPos[1] - 0.5 - y]) + }); + 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) { @@ -549,7 +620,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; @@ -585,6 +656,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 5283cca6bf2..51d76f3bf2a 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -488,7 +488,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(); -}); From b6587dee2c48ab9fd0c633181e1576b9ca4da28e Mon Sep 17 00:00:00 2001 From: Mikko Pulkki Date: Wed, 13 Nov 2019 18:04:40 +0200 Subject: [PATCH 2/4] More tileCoverage tests --- test/unit/geo/transform.test.js | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/unit/geo/transform.test.js b/test/unit/geo/transform.test.js index bfb7f82e29c..4cd54cb3e92 100644 --- a/test/unit/geo/transform.test.js +++ b/test/unit/geo/transform.test.js @@ -153,6 +153,54 @@ test('transform', (t) => { new OverscaledTileID(10, 0, 10, 511, 512), new OverscaledTileID(10, 0, 10, 512, 512)]); + transform.zoom = 5.1; + transform.pitch = 60.0; + transform.bearing = 32.0; + transform.center = new LngLat(56.90, 48.20); + transform.resize(1024, 768); + t.deepEqual(transform.coveringTiles(options), [ + new OverscaledTileID(5, 0, 5, 21, 11), + new OverscaledTileID(5, 0, 5, 20, 11), + new OverscaledTileID(5, 0, 5, 21, 10), + new OverscaledTileID(5, 0, 5, 20, 10), + new OverscaledTileID(5, 0, 5, 21, 12), + new OverscaledTileID(5, 0, 5, 22, 11), + new OverscaledTileID(5, 0, 5, 20, 12), + new OverscaledTileID(5, 0, 5, 22, 10), + new OverscaledTileID(5, 0, 5, 21, 9), + new OverscaledTileID(5, 0, 5, 20, 9), + new OverscaledTileID(5, 0, 5, 22, 9), + new OverscaledTileID(5, 0, 5, 23, 10), + new OverscaledTileID(5, 0, 5, 21, 8), + new OverscaledTileID(5, 0, 5, 20, 8), + new OverscaledTileID(5, 0, 5, 23, 9), + new OverscaledTileID(5, 0, 5, 22, 8), + new OverscaledTileID(5, 0, 5, 23, 8), + new OverscaledTileID(5, 0, 5, 21, 7), + new OverscaledTileID(5, 0, 5, 20, 7), + new OverscaledTileID(5, 0, 5, 24, 9), + new OverscaledTileID(5, 0, 5, 22, 7) + ]); + + transform.zoom = 8; + transform.pitch = 60; + transform.bearing = 45.0; + transform.center = new LngLat(25.02, 60.15); + transform.resize(300, 50); + t.deepEqual(transform.coveringTiles(options), [ + new OverscaledTileID(8, 0, 8, 145, 74), + new OverscaledTileID(8, 0, 8, 145, 73), + new OverscaledTileID(8, 0, 8, 146, 74) + ]); + + transform.resize(50, 300); + t.deepEqual(transform.coveringTiles(options), [ + new OverscaledTileID(8, 0, 8, 145, 74), + new OverscaledTileID(8, 0, 8, 145, 73), + new OverscaledTileID(8, 0, 8, 146, 74), + new OverscaledTileID(8, 0, 8, 146, 73) + ]); + t.end(); }); From 6019c2104c283e4a5f516d273556b6f053d312f6 Mon Sep 17 00:00:00 2001 From: Mikko Pulkki Date: Tue, 14 Jan 2020 11:47:44 +0200 Subject: [PATCH 3/4] Add aabb and frustum unit tests --- test/unit/util/primitives.test.js | 158 ++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 test/unit/util/primitives.test.js diff --git a/test/unit/util/primitives.test.js b/test/unit/util/primitives.test.js new file mode 100644 index 00000000000..cc5d8c8b848 --- /dev/null +++ b/test/unit/util/primitives.test.js @@ -0,0 +1,158 @@ +import {test} from '../../util/test'; +import {Aabb, Frustum} from '../../../src/util/primitives'; +import {mat4, vec3} from 'gl-matrix'; + +test('primitives', (t) => { + t.test('aabb', (t) => { + t.test('Create an aabb', (t) => { + const min = vec3.fromValues(0, 0, 0); + const max = vec3.fromValues(2, 4, 6); + const aabb = new Aabb(min, max); + + t.equal(aabb.min, min); + t.equal(aabb.max, max); + t.deepEqual(aabb.center, vec3.fromValues(1, 2, 3)); + t.end(); + }); + + t.test('Create 4 quadrants', (t) => { + const min = vec3.fromValues(0, 0, 0); + const max = vec3.fromValues(2, 4, 1); + const aabb = new Aabb(min, max); + + t.deepEqual(aabb.quadrant(0), new Aabb(vec3.fromValues(0, 0, 0), vec3.fromValues(1, 2, 1))); + t.deepEqual(aabb.quadrant(1), new Aabb(vec3.fromValues(1, 0, 0), vec3.fromValues(2, 2, 1))); + t.deepEqual(aabb.quadrant(2), new Aabb(vec3.fromValues(0, 2, 0), vec3.fromValues(1, 4, 1))); + t.deepEqual(aabb.quadrant(3), new Aabb(vec3.fromValues(1, 2, 0), vec3.fromValues(2, 4, 1))); + + t.end(); + }); + + t.test('Closest point inside of aabb', (t) => { + const min = vec3.fromValues(-1, -1, -1); + const max = vec3.fromValues(1, 1, 1); + const aabb = new Aabb(min, max); + + t.deepEqual(aabb.closestPoint([0.5, -0.5]), [0.5, -0.5]); + t.deepEqual(aabb.closestPoint([0, 10]), [0, 1]); + t.deepEqual(aabb.closestPoint([-2, -2]), [-1, -1]); + t.end(); + }); + + t.test('Distance to a point', (t) => { + const min = vec3.fromValues(-1, -1, -1); + const max = vec3.fromValues(1, 1, 1); + const aabb = new Aabb(min, max); + + t.deepEqual(aabb.distanceXY([0.5, -0.5]), [0, 0]); + t.deepEqual(aabb.distanceXY([1, 1]), [0, 0]); + t.deepEqual(aabb.distanceXY([0, 10]), [0, -9]); + t.deepEqual(aabb.distanceXY([-2, -2]), [1, 1]); + t.end(); + }); + + const createTestCameraFrustum = (fovy, aspectRatio, zNear, zFar, elevation, rotation) => { + const proj = new Float64Array(16); + const invProj = new Float64Array(16); + // Note that left handed coordinate space is used where z goes towards the sky. + // Y has to be flipped as well because it's part of the projection/camera matrix used in transform.js + mat4.perspective(proj, fovy, aspectRatio, zNear, zFar); + mat4.scale(proj, proj, [1, -1, 1]); + mat4.translate(proj, proj, [0, 0, elevation]); + mat4.rotateZ(proj, proj, rotation); + mat4.invert(invProj, proj); + + return Frustum.fromInvProjectionMatrix(invProj, 1.0, 0.0); + }; + + t.test('Aabb fully inside a frustum', (t) => { + const frustum = createTestCameraFrustum(Math.PI / 2, 1.0, 0.1, 100.0, -5, 0); + + // Intersection test is done in xy-plane + const aabbList = [ + new Aabb(vec3.fromValues(-1, -1, 0), vec3.fromValues(1, 1, 0)), + new Aabb(vec3.fromValues(-5, -5, 0), vec3.fromValues(5, 5, 0)), + new Aabb(vec3.fromValues(-5, -5, 0), vec3.fromValues(-4, -2, 0)) + ]; + + for (const aabb of aabbList) + t.equal(aabb.intersects(frustum), 'contains'); + + t.end(); + }); + + t.test('Aabb intersecting with a frustum', (t) => { + const frustum = createTestCameraFrustum(Math.PI / 2, 1.0, 0.1, 100.0, -5, 0); + + const aabbList = [ + new Aabb(vec3.fromValues(-6, -6, 0), vec3.fromValues(6, 6, 0)), + new Aabb(vec3.fromValues(-6, -6, 0), vec3.fromValues(-5, -5, 0)) + ]; + + for (const aabb of aabbList) + t.equal(aabb.intersects(frustum), 'intersects'); + + t.end(); + }); + + t.test('No intersection between aabb and frustum', (t) => { + const frustum = createTestCameraFrustum(Math.PI / 2, 1.0, 0.1, 100.0, -5); + + const aabbList = [ + new Aabb(vec3.fromValues(-6, 0, 0), vec3.fromValues(-5.5, 0, 0)), + new Aabb(vec3.fromValues(-6, -6, 0), vec3.fromValues(-5.5, -5.5, 0)), + new Aabb(vec3.fromValues(7, -10, 0), vec3.fromValues(7.1, 20, 0)) + ]; + + for (const aabb of aabbList) + t.equal(aabb.intersects(frustum), 'none'); + + t.end(); + }); + + t.end(); + }); + + t.test('frustum', (t) => { + t.test('Create a frustum from inverse projection matrix', (t) => { + const proj = new Float64Array(16); + const invProj = new Float64Array(16); + mat4.perspective(proj, Math.PI / 2, 1.0, 0.1, 100.0); + mat4.invert(invProj, proj); + + const frustum = Frustum.fromInvProjectionMatrix(invProj, 1.0, 0.0); + + // mat4.perspective generates a projection matrix for right handed coordinate space. + // This means that forward direction will be -z + const expectedFrustumPoints = [ + [-0.1, 0.1, -0.1, 1.0], + [0.1, 0.1, -0.1, 1.0], + [0.1, -0.1, -0.1, 1.0], + [-0.1, -0.1, -0.1, 1.0], + [-100.0, 100.0, -100.0, 1.0], + [100.0, 100.0, -100.0, 1.0], + [100.0, -100.0, -100.0, 1.0], + [-100.0, -100.0, -100.0, 1.0], + ]; + + // Round numbers to mitigate the precision loss + frustum.points = frustum.points.map(array => array.map(n => Math.round(n * 10) / 10)); + frustum.planes = frustum.planes.map(array => array.map(n => Math.round(n * 1000) / 1000)); + + const expectedFrustumPlanes = [ + [0, 0, 1.0, 0.1], + [0, 0, -1.0, -100.0], + [-0.707, 0, 0.707, 0], + [0.707, 0, 0.707, 0], + [0, -0.707, 0.707, 0], + [0, 0.707, 0.707, 0] + ]; + + t.deepEqual(frustum.points, expectedFrustumPoints); + t.deepEqual(frustum.planes, expectedFrustumPlanes); + t.end(); + }); + t.end(); + }); + t.end(); +}); From b62f634d61f5755c00471348c2e9bcc0e1e86f85 Mon Sep 17 00:00:00 2001 From: Mikko Pulkki Date: Tue, 14 Jan 2020 20:54:20 +0200 Subject: [PATCH 4/4] Fixes --- src/geo/transform.js | 28 ++++++++++-------- src/source/source_cache.js | 1 + src/util/primitives.js | 29 ++++++++---------- test/unit/geo/transform.test.js | 49 +++++++++++++++++++++++++++++++ test/unit/util/primitives.test.js | 30 ++++++++----------- 5 files changed, 92 insertions(+), 45 deletions(-) diff --git a/src/geo/transform.js b/src/geo/transform.js index 10dec4243d0..af33f5922d9 100644 --- a/src/geo/transform.js +++ b/src/geo/transform.js @@ -276,12 +276,13 @@ class Transform { if (options.minzoom !== undefined && z < options.minzoom) return []; if (options.maxzoom !== undefined && z > options.maxzoom) z = options.maxzoom; - let minZoom = options.minzoom || 0; const centerCoord = MercatorCoordinate.fromLngLat(this.center); const numTiles = Math.pow(2, z); - const centerPoint = new Point(numTiles * centerCoord.x, numTiles * centerCoord.y); + 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: return only tile ids from the requested zoom level + let minZoom = options.minzoom || 0; if (this.pitch <= 60.0) minZoom = z; @@ -316,10 +317,6 @@ class Transform { 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; @@ -330,28 +327,35 @@ class Transform { if (!fullyVisible) { const intersectResult = it.aabb.intersects(cameraFrustum); - if (intersectResult === 'none') + if (intersectResult === 0) continue; - fullyVisible = intersectResult === 'contains'; + fullyVisible = intersectResult === 2; } - const distanceXY = it.aabb.distanceXY(streamPos); - const longestDim = Math.max(Math.abs(distanceXY[0]), Math.abs(distanceXY[1])); + const distanceX = it.aabb.distanceX(centerPoint); + const distanceY = it.aabb.distanceY(centerPoint); + const longestDim = Math.max(Math.abs(distanceX), Math.abs(distanceY)); + + // We're using distance based heuristics to determine if a tile should be split into quadrants or not. + // radiusOfMaxLvlLodInTiles defines that there's always a certain number of maxLevel tiles next to the map center. + // Using the fact that a parent node in quadtree is twice the size of its children (per dimension) + // we can define distance thresholds for each relative level: + // f(k) = offset + 2 + 4 + 8 + 16 + ... + 2^k. This is the same as "offset+2^(k+1)-2" const distToSplit = radiusOfMaxLvlLodInTiles + (1 << (maxZoom - it.zoom)) - 2; // Have we reached the target depth or is the tile too far away to be any split further? if (it.zoom === maxZoom || (longestDim > distToSplit && it.zoom >= minZoom)) { result.push({ tileID: new OverscaledTileID(it.zoom === maxZoom ? overscaledZ : it.zoom, it.wrap, it.zoom, x, y), - distanceSq: vec2.sqrLen([streamPos[0] - 0.5 - x, streamPos[1] - 0.5 - y]) + distanceSq: vec2.sqrLen([centerPoint[0] - 0.5 - x, centerPoint[1] - 0.5 - y]) }); continue; } for (let i = 0; i < 4; i++) { const childX = (x << 1) + (i % 2); - const childY = (y << 1) + Math.floor(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}); } diff --git a/src/source/source_cache.js b/src/source/source_cache.js index 51d76f3bf2a..5283cca6bf2 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -488,6 +488,7 @@ 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 index 58cfc68f180..b1cd69e6fe3 100644 --- a/src/util/primitives.js +++ b/src/util/primitives.js @@ -3,8 +3,6 @@ import {vec3, vec4} from 'gl-matrix'; import assert from 'assert'; -type IntersectResult = 'none' | 'intersects' | 'contains'; - class Frustum { points: Array>; planes: Array>; @@ -78,20 +76,19 @@ class Aabb { 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]; + distanceX(point: Array): number { + const pointOnAabb = Math.max(Math.min(this.max[0], point[0]), this.min[0]); + return pointOnAabb - point[0]; } - distanceXY(point: Array): Array { - const aabbPoint = this.closestPoint(point); - const dx = aabbPoint[0] - point[0]; - const dy = aabbPoint[1] - point[1]; - return [dx, dy]; + distanceY(point: Array): number { + const pointOnAabb = Math.max(Math.min(this.max[1], point[1]), this.min[1]); + return pointOnAabb - point[1]; } - intersects(frustum: Frustum): IntersectResult { + // Performs a frustum-aabb intersection test. Returns 0 if there's no intersection, + // 1 if shapes are intersecting and 2 if the aabb if fully inside the frustum. + intersects(frustum: Frustum): number { // 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 @@ -115,14 +112,14 @@ class Aabb { } if (pointsInside === 0) - return 'none'; + return 0; if (pointsInside !== aabbPoints.length) fullyInside = false; } if (fullyInside) - return 'contains'; + return 2; for (let axis = 0; axis < 3; axis++) { let projMin = Number.MAX_VALUE; @@ -136,10 +133,10 @@ class Aabb { } if (projMax < 0 || projMin > this.max[axis] - this.min[axis]) - return 'none'; + return 0; } - return 'intersects'; + return 1; } } export { diff --git a/test/unit/geo/transform.test.js b/test/unit/geo/transform.test.js index 4cd54cb3e92..8a9632c516e 100644 --- a/test/unit/geo/transform.test.js +++ b/test/unit/geo/transform.test.js @@ -201,6 +201,55 @@ test('transform', (t) => { new OverscaledTileID(8, 0, 8, 146, 73) ]); + transform.zoom = 2; + transform.pitch = 0; + transform.bearing = 0; + transform.resize(300, 300); + t.test('calculates tile coverage at w > 0', (t) => { + transform.center = {lng: 630.01, lat: 0.01}; + t.deepEqual(transform.coveringTiles(options), [ + new OverscaledTileID(2, 2, 2, 1, 1), + new OverscaledTileID(2, 2, 2, 1, 2), + new OverscaledTileID(2, 2, 2, 0, 1), + new OverscaledTileID(2, 2, 2, 0, 2) + ]); + t.end(); + }); + + t.test('calculates tile coverage at w = -1', (t) => { + transform.center = {lng: -360.01, lat: 0.01}; + t.deepEqual(transform.coveringTiles(options), [ + new OverscaledTileID(2, -1, 2, 1, 1), + new OverscaledTileID(2, -1, 2, 1, 2), + new OverscaledTileID(2, -1, 2, 2, 1), + new OverscaledTileID(2, -1, 2, 2, 2) + ]); + t.end(); + }); + + t.test('calculates tile coverage across meridian', (t) => { + transform.zoom = 1; + transform.center = {lng: -180.01, lat: 0.01}; + t.deepEqual(transform.coveringTiles(options), [ + new OverscaledTileID(1, 0, 1, 0, 0), + new OverscaledTileID(1, 0, 1, 0, 1), + new OverscaledTileID(1, -1, 1, 1, 0), + new OverscaledTileID(1, -1, 1, 1, 1) + ]); + t.end(); + }); + + t.test('only includes tiles for a single world, if renderWorldCopies is set to false', (t) => { + transform.zoom = 1; + transform.center = {lng: -180.01, lat: 0.01}; + transform.renderWorldCopies = false; + t.deepEqual(transform.coveringTiles(options), [ + new OverscaledTileID(1, 0, 1, 0, 0), + new OverscaledTileID(1, 0, 1, 0, 1) + ]); + t.end(); + }); + t.end(); }); diff --git a/test/unit/util/primitives.test.js b/test/unit/util/primitives.test.js index cc5d8c8b848..35b56d50cba 100644 --- a/test/unit/util/primitives.test.js +++ b/test/unit/util/primitives.test.js @@ -28,26 +28,22 @@ test('primitives', (t) => { t.end(); }); - t.test('Closest point inside of aabb', (t) => { + t.test('Distance to a point', (t) => { const min = vec3.fromValues(-1, -1, -1); const max = vec3.fromValues(1, 1, 1); const aabb = new Aabb(min, max); - t.deepEqual(aabb.closestPoint([0.5, -0.5]), [0.5, -0.5]); - t.deepEqual(aabb.closestPoint([0, 10]), [0, 1]); - t.deepEqual(aabb.closestPoint([-2, -2]), [-1, -1]); - t.end(); - }); + t.equal(aabb.distanceX([0.5, -0.5]), 0); + t.equal(aabb.distanceY([0.5, -0.5]), 0); - t.test('Distance to a point', (t) => { - const min = vec3.fromValues(-1, -1, -1); - const max = vec3.fromValues(1, 1, 1); - const aabb = new Aabb(min, max); + t.equal(aabb.distanceX([1, 1]), 0); + t.equal(aabb.distanceY([1, 1]), 0); + + t.equal(aabb.distanceX([0, 10]), 0); + t.equal(aabb.distanceY([0, 10]), -9); - t.deepEqual(aabb.distanceXY([0.5, -0.5]), [0, 0]); - t.deepEqual(aabb.distanceXY([1, 1]), [0, 0]); - t.deepEqual(aabb.distanceXY([0, 10]), [0, -9]); - t.deepEqual(aabb.distanceXY([-2, -2]), [1, 1]); + t.equal(aabb.distanceX([-2, -2]), 1); + t.equal(aabb.distanceY([-2, -2]), 1); t.end(); }); @@ -76,7 +72,7 @@ test('primitives', (t) => { ]; for (const aabb of aabbList) - t.equal(aabb.intersects(frustum), 'contains'); + t.equal(aabb.intersects(frustum), 2); t.end(); }); @@ -90,7 +86,7 @@ test('primitives', (t) => { ]; for (const aabb of aabbList) - t.equal(aabb.intersects(frustum), 'intersects'); + t.equal(aabb.intersects(frustum), 1); t.end(); }); @@ -105,7 +101,7 @@ test('primitives', (t) => { ]; for (const aabb of aabbList) - t.equal(aabb.intersects(frustum), 'none'); + t.equal(aabb.intersects(frustum), 0); t.end(); });