Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use frustum culling in OSM tile traversal #4593

Merged
merged 2 commits into from
May 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions examples/website/map-tile/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down Expand Up @@ -40,7 +41,7 @@ export default class App extends PureComponent {
}

_renderLayers() {
const {autoHighlight = true, highlightColor = [60, 60, 60, 40]} = this.props;
const {showBorder = false, onTilesLoad = null} = this.props;

return [
new TileLayer({
Expand All @@ -49,8 +50,9 @@ export default class App extends PureComponent {

pickable: true,
onHover: this._onHover,
autoHighlight,
highlightColor,
onViewportLoad: onTilesLoad,
autoHighlight: showBorder,
highlightColor: [60, 60, 60, 40],
// https://wiki.openstreetmap.org/wiki/Zoom_levels
minZoom: 0,
maxZoom: 19,
Expand All @@ -60,11 +62,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
})
];
}
})
];
Expand Down
137 changes: 137 additions & 0 deletions modules/geo-layers/src/tile-layer/tile-2d-traversal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/* eslint-disable complexity */
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
// 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));
}
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;
Copy link
Collaborator

@kylebarron kylebarron May 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary to keep in sync with basemaps, e.g. Mapbox GL? I could envision cases where a user might want to relax this to load fewer tiles at moderate pitch, e.g. 40-60 degrees. But I guess relaxing this by default would be a backwards incompatible change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also what mapbox-gl is doing. Lowering the threshold to 40-50 saves 2-3 tile loads tops, and may have unexpected behavioral changes for existing users (60 is the default maxPitch)


const root = new OSMNode(0, 0, 0);
const traversalParams = {
viewport,
cullingVolume,
elevationBounds: [elevationMin, elevationMax],
minZ,
maxZ,
// num. of worlds from the center. For repeated maps
offset: 0
};

root.update(traversalParams);

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;
}
}
}

return root.getSelected();
}
2 changes: 1 addition & 1 deletion modules/geo-layers/src/tile-layer/tileset-2d.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 1 addition & 45 deletions modules/geo-layers/src/tile-layer/utils.js
Original file line number Diff line number Diff line change
@@ -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];
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 31 additions & 21 deletions test/modules/geo-layers/tile-layer/utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,34 +34,43 @@ 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'
]
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: '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',
Expand Down Expand Up @@ -104,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']
Expand Down
6 changes: 5 additions & 1 deletion website/src/actions/app-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading