From cc17865f7f639251a94c64c028047ab0eafa09d8 Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Sat, 2 May 2020 00:00:36 -0700 Subject: [PATCH 1/2] use frustum culling in 2d TileLayer --- examples/website/map-tile/app.js | 29 ++-- .../src/tile-layer/tile-2d-traversal.js | 132 ++++++++++++++++++ .../geo-layers/src/tile-layer/tileset-2d.js | 2 +- modules/geo-layers/src/tile-layer/utils.js | 46 +----- .../geo-layers/tile-layer/utils.spec.js | 40 ++++-- website/src/components/demos/map-tile-demo.js | 4 +- 6 files changed, 186 insertions(+), 67 deletions(-) create mode 100644 modules/geo-layers/src/tile-layer/tile-2d-traversal.js diff --git a/examples/website/map-tile/app.js b/examples/website/map-tile/app.js index f45e3ef3aa1..04b5259c329 100644 --- a/examples/website/map-tile/app.js +++ b/examples/website/map-tile/app.js @@ -4,13 +4,14 @@ import {render} from 'react-dom'; import DeckGL from '@deck.gl/react'; import {MapView} from '@deck.gl/core'; import {TileLayer} from '@deck.gl/geo-layers'; -import {BitmapLayer} from '@deck.gl/layers'; +import {BitmapLayer, PathLayer} from '@deck.gl/layers'; const INITIAL_VIEW_STATE = { latitude: 47.65, longitude: 7, zoom: 4.5, maxZoom: 20, + maxPitch: 89, bearing: 0 }; @@ -40,7 +41,7 @@ export default class App extends PureComponent { } _renderLayers() { - const {autoHighlight = true, highlightColor = [60, 60, 60, 40]} = this.props; + const {showBorder = false} = this.props; return [ new TileLayer({ @@ -49,8 +50,8 @@ export default class App extends PureComponent { pickable: true, onHover: this._onHover, - autoHighlight, - highlightColor, + autoHighlight: showBorder, + highlightColor: [60, 60, 60, 40], // https://wiki.openstreetmap.org/wiki/Zoom_levels minZoom: 0, maxZoom: 19, @@ -60,11 +61,21 @@ export default class App extends PureComponent { bbox: {west, south, east, north} } = props.tile; - return new BitmapLayer(props, { - data: null, - image: props.data, - bounds: [west, south, east, north] - }); + return [ + new BitmapLayer(props, { + data: null, + image: props.data, + bounds: [west, south, east, north] + }), + showBorder && + new PathLayer({ + id: `${props.id}-border`, + data: [[[west, north], [west, south], [east, south], [east, north], [west, north]]], + getPath: d => d, + getColor: [255, 0, 0], + widthMinPixels: 4 + }) + ]; } }) ]; diff --git a/modules/geo-layers/src/tile-layer/tile-2d-traversal.js b/modules/geo-layers/src/tile-layer/tile-2d-traversal.js new file mode 100644 index 00000000000..9ef9967e147 --- /dev/null +++ b/modules/geo-layers/src/tile-layer/tile-2d-traversal.js @@ -0,0 +1,132 @@ +import {CullingVolume, Plane, AxisAlignedBoundingBox} from '@math.gl/culling'; + +const TILE_SIZE = 512; +// number of world copies to check +const MAX_MAPS = 3; + +class OSMNode { + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } + + get children() { + if (!this._children) { + const x = this.x * 2; + const y = this.y * 2; + const z = this.z + 1; + this._children = [ + new OSMNode(x, y, z), + new OSMNode(x, y + 1, z), + new OSMNode(x + 1, y, z), + new OSMNode(x + 1, y + 1, z) + ]; + } + return this._children; + } + + update(params) { + const {viewport, cullingVolume, elevationBounds, minZ, maxZ, offset} = params; + const boundingVolume = this.getBoundingVolume(elevationBounds, offset); + + // First, check if this tile is visible + const isInside = cullingVolume.computeVisibility(boundingVolume); + if (isInside < 0) { + return false; + } + + // Avoid loading overlapping tiles - if a descendant is requested, do not request the ancester + if (!this.childVisible) { + let {z} = this; + if (z < maxZ && z >= minZ) { + // Adjust LOD by distance to camera + const distance = + (boundingVolume.distanceTo(viewport.cameraPosition) * viewport.scale) / viewport.height; + z += Math.floor(Math.log2(distance)); + } + if (z >= maxZ) { + // LOD is acceptable + this.selected = true; + return true; + } + } + + // LOD is not enough, recursively test child tiles + this.selected = false; + this.childVisible = true; + for (const child of this.children) { + child.update(params); + } + return true; + } + + getSelected(result = []) { + if (this.selected) { + result.push(this); + } + if (this._children) { + for (const node of this._children) { + node.getSelected(result); + } + } + return result; + } + + getBoundingVolume(zRange, worldOffset) { + const scale = Math.pow(2, this.z); + const extent = TILE_SIZE / scale; + const originX = this.x * extent + worldOffset * TILE_SIZE; + // deck's common space is y-flipped + const originY = TILE_SIZE - (this.y + 1) * extent; + + return new AxisAlignedBoundingBox( + [originX, originY, zRange[0]], + [originX + extent, originY + extent, zRange[1]] + ); + } +} + +export function getOSMTileIndices(viewport, maxZ, zRange) { + // Get the culling volume of the current camera + const planes = Object.values(viewport.getFrustumPlanes()).map( + ({normal, distance}) => new Plane(normal.clone().negate(), distance) + ); + const cullingVolume = new CullingVolume(planes); + + // Project zRange from meters to common space + const unitsPerMeter = viewport.distanceScales.unitsPerMeter[2]; + const elevationMin = (zRange && zRange[0] * unitsPerMeter) || 0; + const elevationMax = (zRange && zRange[1] * unitsPerMeter) || 0; + + // Always load at the current zoom level if pitch is small + const minZ = viewport.pitch <= 60 ? maxZ : 0; + + const root = new OSMNode(0, 0, 0); + const traversalParams = { + viewport, + cullingVolume, + elevationBounds: [elevationMin, elevationMax], + minZ, + maxZ, + offset: 0 + }; + + root.update(traversalParams); + + // Check worlds in repeated maps + traversalParams.offset = -1; + while (root.update(traversalParams)) { + if (--traversalParams.offset < -MAX_MAPS) { + break; + } + } + traversalParams.offset = 1; + while (root.update(traversalParams)) { + if (++traversalParams.offset > -MAX_MAPS) { + break; + } + } + + return root.getSelected(); +} diff --git a/modules/geo-layers/src/tile-layer/tileset-2d.js b/modules/geo-layers/src/tile-layer/tileset-2d.js index 664e5599b00..992643d1100 100644 --- a/modules/geo-layers/src/tile-layer/tileset-2d.js +++ b/modules/geo-layers/src/tile-layer/tileset-2d.js @@ -98,7 +98,7 @@ export default class Tileset2D { * @param {*} onUpdate */ update(viewport, {zRange} = {}) { - if (viewport !== this._viewport) { + if (!viewport.equals(this._viewport)) { this._viewport = viewport; const tileIndices = this.getTileIndices({ viewport, diff --git a/modules/geo-layers/src/tile-layer/utils.js b/modules/geo-layers/src/tile-layer/utils.js index 2757e316a40..6b0c3cbbf67 100644 --- a/modules/geo-layers/src/tile-layer/utils.js +++ b/modules/geo-layers/src/tile-layer/utils.js @@ -1,4 +1,4 @@ -import {lngLatToWorld} from '@math.gl/web-mercator'; +import {getOSMTileIndices} from './tile-2d-traversal'; const TILE_SIZE = 512; const DEFAULT_EXTENT = [-Infinity, -Infinity, Infinity, Infinity]; @@ -66,17 +66,6 @@ function getBoundingBox(viewport, zRange, extent) { ]; } -/* - * get the OSM tile index at the given location - * https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames - */ -function getOSMTileIndex(lngLat, scale) { - let [x, y] = lngLatToWorld(lngLat); - x *= scale / TILE_SIZE; - y = (1 - y / TILE_SIZE) * scale; - return [x, y]; -} - function getTileIndex([x, y], scale) { return [(x * scale) / TILE_SIZE, (y * scale) / TILE_SIZE]; } @@ -130,39 +119,6 @@ function getIdentityTileIndices(viewport, z, tileSize, extent) { return indices; } -function getOSMTileIndices(viewport, z, zRange, extent) { - const bbox = getBoundingBox(viewport, zRange, extent); - const scale = getScale(z); - /* - minX, maxX could be out of bounds if longitude is near the 180 meridian or multiple worlds - are shown: - | | - actual -2 -1 0 1 2 3 - expected 2 3 0 1 2 3 - */ - let [minX, minY] = getOSMTileIndex([bbox[0], bbox[3]], scale); - let [maxX, maxY] = getOSMTileIndex([bbox[2], bbox[1]], scale); - const indices = []; - - /* - | TILE | TILE | TILE | - |(minX) |(maxX) - */ - minX = Math.floor(minX); - maxX = Math.min(minX + scale, maxX); // Avoid creating duplicates - minY = Math.max(0, Math.floor(minY)); - maxY = Math.min(scale, maxY); - for (let x = minX; x < maxX; x++) { - for (let y = minY; y < maxY; y++) { - // Cast to valid x between [0, scale] - const normalizedX = x - Math.floor(x / scale) * scale; - indices.push({x: normalizedX, y, z}); - } - } - - return indices; -} - /** * Returns all tile indices in the current viewport. If the current zoom level is smaller * than minZoom, return an empty array. If the current zoom level is greater than maxZoom, diff --git a/test/modules/geo-layers/tile-layer/utils.spec.js b/test/modules/geo-layers/tile-layer/utils.spec.js index 2ce8e996fcf..f352727f0fc 100644 --- a/test/modules/geo-layers/tile-layer/utils.spec.js +++ b/test/modules/geo-layers/tile-layer/utils.spec.js @@ -35,33 +35,53 @@ const TEST_CASES = [ minZoom: undefined, maxZoom: undefined, output: [ - '0,1,3', '0,2,3', '0,3,3', - '1,1,3', '1,2,3', '1,3,3', '2,1,3', '2,2,3', '2,3,3', - '3,1,3', '3,2,3', - '3,3,3' + '3,3,3', + '7,2,3' ] }, { - title: 'flat viewport (exact)', + title: 'extreme pitch', viewport: new WebMercatorViewport({ - width: 1024, - height: 1024, + width: 800, + height: 400, + pitch: 75, + bearing: 0, longitude: 0, latitude: 0, - orthographic: true, - zoom: 2 + zoom: 4 }), minZoom: undefined, maxZoom: undefined, - output: ['1,1,2', '1,2,2', '2,1,2', '2,2,2'] + output: [ + '0,0,2', + '1,0,2', + '2,0,2', + '2,2,3', + '2,3,3', + '3,0,2', + '3,2,3', + '4,2,3', + '5,2,3', + '5,3,3', + '6,6,4', + '6,7,4', + '7,6,4', + '7,7,4', + '7,8,4', + '8,6,4', + '8,7,4', + '8,8,4', + '9,6,4', + '9,7,4' + ] }, { title: 'under zoom', diff --git a/website/src/components/demos/map-tile-demo.js b/website/src/components/demos/map-tile-demo.js index 1a3900be1a2..90602a164a9 100644 --- a/website/src/components/demos/map-tile-demo.js +++ b/website/src/components/demos/map-tile-demo.js @@ -5,7 +5,7 @@ import App from 'website-examples/map-tile/app'; export default class MapTileDemo extends Component { static get parameters() { return { - autoHighlight: {displayName: 'Highlight on hover', type: 'checkbox', value: true} + showBorder: {displayName: 'Show tile borders', type: 'checkbox', value: false} }; } @@ -29,6 +29,6 @@ export default class MapTileDemo extends Component { render() { // eslint-disable-next-line no-unused-vars const {params, data, ...otherProps} = this.props; - return ; + return ; } } From 6cc65aefe9d81c8916dbe4286f9e899bd255dd5e Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Tue, 26 May 2020 14:50:01 -0700 Subject: [PATCH 2/2] address comments --- examples/website/map-tile/app.js | 3 ++- .../src/tile-layer/tile-2d-traversal.js | 27 +++++++++++-------- .../geo-layers/tile-layer/utils.spec.js | 16 +++-------- website/src/actions/app-actions.js | 6 ++++- website/src/components/demos/map-tile-demo.js | 13 +++++++-- website/src/components/info-panel.js | 5 ++-- website/webpack.config.js | 3 ++- 7 files changed, 41 insertions(+), 32 deletions(-) diff --git a/examples/website/map-tile/app.js b/examples/website/map-tile/app.js index 04b5259c329..0dd854cef24 100644 --- a/examples/website/map-tile/app.js +++ b/examples/website/map-tile/app.js @@ -41,7 +41,7 @@ export default class App extends PureComponent { } _renderLayers() { - const {showBorder = false} = this.props; + const {showBorder = false, onTilesLoad = null} = this.props; return [ new TileLayer({ @@ -50,6 +50,7 @@ export default class App extends PureComponent { pickable: true, onHover: this._onHover, + onViewportLoad: onTilesLoad, autoHighlight: showBorder, highlightColor: [60, 60, 60, 40], // https://wiki.openstreetmap.org/wiki/Zoom_levels diff --git a/modules/geo-layers/src/tile-layer/tile-2d-traversal.js b/modules/geo-layers/src/tile-layer/tile-2d-traversal.js index 9ef9967e147..409676867a7 100644 --- a/modules/geo-layers/src/tile-layer/tile-2d-traversal.js +++ b/modules/geo-layers/src/tile-layer/tile-2d-traversal.js @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import {CullingVolume, Plane, AxisAlignedBoundingBox} from '@math.gl/culling'; const TILE_SIZE = 512; @@ -40,7 +41,8 @@ class OSMNode { if (!this.childVisible) { let {z} = this; if (z < maxZ && z >= minZ) { - // Adjust LOD by distance to camera + // Adjust LOD + // If the tile is far enough from the camera, accept a lower zoom level const distance = (boundingVolume.distanceTo(viewport.cameraPosition) * viewport.scale) / viewport.height; z += Math.floor(Math.log2(distance)); @@ -109,22 +111,25 @@ export function getOSMTileIndices(viewport, maxZ, zRange) { elevationBounds: [elevationMin, elevationMax], minZ, maxZ, + // num. of worlds from the center. For repeated maps offset: 0 }; root.update(traversalParams); - // Check worlds in repeated maps - traversalParams.offset = -1; - while (root.update(traversalParams)) { - if (--traversalParams.offset < -MAX_MAPS) { - break; + if (viewport.subViewports && viewport.subViewports.length > 1) { + // Check worlds in repeated maps + traversalParams.offset = -1; + while (root.update(traversalParams)) { + if (--traversalParams.offset < -MAX_MAPS) { + break; + } } - } - traversalParams.offset = 1; - while (root.update(traversalParams)) { - if (++traversalParams.offset > -MAX_MAPS) { - break; + traversalParams.offset = 1; + while (root.update(traversalParams)) { + if (++traversalParams.offset > MAX_MAPS) { + break; + } } } diff --git a/test/modules/geo-layers/tile-layer/utils.spec.js b/test/modules/geo-layers/tile-layer/utils.spec.js index f352727f0fc..6aab606e061 100644 --- a/test/modules/geo-layers/tile-layer/utils.spec.js +++ b/test/modules/geo-layers/tile-layer/utils.spec.js @@ -34,18 +34,7 @@ const TEST_CASES = [ }), minZoom: undefined, maxZoom: undefined, - output: [ - '0,2,3', - '0,3,3', - '1,2,3', - '1,3,3', - '2,1,3', - '2,2,3', - '2,3,3', - '3,2,3', - '3,3,3', - '7,2,3' - ] + output: ['0,2,3', '0,3,3', '1,2,3', '1,3,3', '2,1,3', '2,2,3', '2,3,3', '3,2,3', '3,3,3'] }, { title: 'extreme pitch', @@ -124,7 +113,8 @@ const TEST_CASES = [ height: 200, longitude: -152, latitude: 0, - zoom: 3 + zoom: 3, + repeat: true }), maxZoom: 2, output: ['0,1,2', '0,2,2', '3,1,2', '3,2,2'] diff --git a/website/src/actions/app-actions.js b/website/src/actions/app-actions.js index 7911f5cd547..ac2e3045f75 100644 --- a/website/src/actions/app-actions.js +++ b/website/src/actions/app-actions.js @@ -59,10 +59,14 @@ const loadDataSuccess = (context, index, data, meta) => { */ export const loadData = (owner, source) => { return (dispatch, getState) => { - if (getState().vis.owner === owner || !source) { + if (getState().vis.owner === owner) { // already loading / loaded return; } + dispatch(loadDataStart(owner)); + if (!source) { + return; + } const isArray = Array.isArray(source); diff --git a/website/src/components/demos/map-tile-demo.js b/website/src/components/demos/map-tile-demo.js index 90602a164a9..e36887275e0 100644 --- a/website/src/components/demos/map-tile-demo.js +++ b/website/src/components/demos/map-tile-demo.js @@ -1,4 +1,5 @@ import React, {Component} from 'react'; +import autobind from 'autobind-decorator'; import {MAPBOX_STYLES} from '../../constants/defaults'; import App from 'website-examples/map-tile/app'; @@ -13,7 +14,7 @@ export default class MapTileDemo extends Component { return MAPBOX_STYLES.BLANK; } - static renderInfo() { + static renderInfo(meta) { return (

Raster Map Tiles

@@ -22,13 +23,21 @@ export default class MapTileDemo extends Component { Wiki and Tile Servers

+
+ No. of Tiles Loaded{meta.tileCount || 0} +
); } + @autobind + _onTilesLoad(tiles) { + this.props.onStateChange({tileCount: tiles.length}); + } + render() { // eslint-disable-next-line no-unused-vars const {params, data, ...otherProps} = this.props; - return ; + return ; } } diff --git a/website/src/components/info-panel.js b/website/src/components/info-panel.js index eb24ebca56f..59428d02484 100644 --- a/website/src/components/info-panel.js +++ b/website/src/components/info-panel.js @@ -35,7 +35,6 @@ class InfoPanel extends Component { const {demo, params, owner, meta} = this.props; const {hasFocus} = this.state; const DemoComponent = Demos[demo]; - const metaLoaded = owner === demo ? meta : {}; return (
- {DemoComponent.renderInfo(metaLoaded)} + {DemoComponent.renderInfo(meta)} {Object.keys(params).length > 0 &&
} @@ -61,7 +60,7 @@ class InfoPanel extends Component { {this.props.children} - +
); } diff --git a/website/webpack.config.js b/website/webpack.config.js index 84af74d8fae..4b917abd411 100644 --- a/website/webpack.config.js +++ b/website/webpack.config.js @@ -74,7 +74,8 @@ const COMMON_CONFIG = { modules: [resolve('./node_modules'), resolve('../node_modules')], alias: Object.assign({}, ALIASES, { 'website-examples': resolve('../examples/website'), - 'viewport-mercator-project': resolve('../node_modules/viewport-mercator-project'), + '@math.gl/web-mercator': resolve('../node_modules/@math.gl/web-mercator'), + '@math.gl/culling': resolve('../node_modules/@math.gl/culling'), supercluster: resolve('../node_modules/supercluster/dist/supercluster.js') }) },