diff --git a/src/source/source_cache.js b/src/source/source_cache.js index 9ff5e99ebd0..1dc68cdc77b 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -212,6 +212,8 @@ class SourceCache extends Evented { if (err) { tile.state = 'errored'; if (err.status !== 404) this._source.fire('error', {tile: tile, error: err}); + // continue to try loading parent/children tiles if a tile doesn't exist (404) + else this.update(this.transform); return; } @@ -333,34 +335,19 @@ class SourceCache extends Evented { this.transform = transform; if (!this._sourceLoaded || this._paused) { return; } - let i; - let coord; - let tile; - let parentTile; - this.updateCacheSize(transform); - // Determine the overzooming/underzooming amounts. - const zoom = (this._source.roundZoom ? Math.round : Math.floor)(this.getZoom(transform)); - const minCoveringZoom = Math.max(zoom - SourceCache.maxOverzooming, this._source.minzoom); - const maxCoveringZoom = Math.max(zoom + SourceCache.maxUnderzooming, this._source.minzoom); - - // 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. - const retain = {}; - - // Covered is a list of retained tiles who's areas are full covered by other, + // Covered is a list of retained tiles who's areas are fully covered by other, // better, retained tiles. They are not drawn separately. this._coveredTiles = {}; - let visibleCoords; + let idealTileCoords; if (!this.used) { - visibleCoords = []; + idealTileCoords = []; } else if (this._source.coord) { - visibleCoords = transform.getVisibleWrappedCoordinates((this._source.coord: any)); + idealTileCoords = transform.getVisibleWrappedCoordinates((this._source.coord: any)); } else { - visibleCoords = transform.coveringTiles({ + idealTileCoords = transform.coveringTiles({ tileSize: this._source.tileSize, minzoom: this._source.minzoom, maxzoom: this._source.maxzoom, @@ -369,28 +356,19 @@ class SourceCache extends Evented { }); if (this._source.hasTile) { - visibleCoords = visibleCoords.filter((coord) => (this._source.hasTile: any)(coord)); + idealTileCoords = idealTileCoords.filter((coord) => (this._source.hasTile: any)(coord)); } } - for (i = 0; i < visibleCoords.length; i++) { - coord = visibleCoords[i]; - tile = this._addTile(coord); - - retain[coord.id] = true; - - if (tile.hasData()) - continue; + // Determine the overzooming/underzooming amounts. + const zoom = (this._source.roundZoom ? Math.round : Math.floor)(this.getZoom(transform)); + const minCoveringZoom = Math.max(zoom - SourceCache.maxOverzooming, this._source.minzoom); + const maxCoveringZoom = Math.max(zoom + SourceCache.maxUnderzooming, this._source.minzoom); - // The tile we require is not yet loaded. - // Retain child or parent tiles that cover the same area. - if (!this._findLoadedChildren(coord, maxCoveringZoom, retain)) { - parentTile = this.findLoadedParent(coord, minCoveringZoom, retain); - if (parentTile) { - this._addTile(parentTile.coord); - } - } - } + // 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. + const retain = this._updateRetainedTiles(idealTileCoords, zoom); const parentsForFading = {}; @@ -398,8 +376,8 @@ class SourceCache extends Evented { const ids = Object.keys(retain); for (let k = 0; k < ids.length; k++) { const id = ids[k]; - coord = TileCoord.fromID(+id); - tile = this._tiles[id]; + const coord = TileCoord.fromID(+id); + const tile = this._tiles[id]; if (!tile) continue; // If the drawRasterTile has never seen this tile, then @@ -410,7 +388,7 @@ class SourceCache extends Evented { if (this._findLoadedChildren(coord, maxCoveringZoom, retain)) { retain[id] = true; } - parentTile = this.findLoadedParent(coord, minCoveringZoom, parentsForFading); + const parentTile = this.findLoadedParent(coord, minCoveringZoom, parentsForFading); if (parentTile) { this._addTile(parentTile.coord); } @@ -428,14 +406,99 @@ class SourceCache extends Evented { for (fadedParent in parentsForFading) { retain[fadedParent] = true; } - // Remove the tiles we don't need anymore. const remove = util.keysDifference(this._tiles, retain); - for (i = 0; i < remove.length; i++) { + for (let i = 0; i < remove.length; i++) { this._removeTile(remove[i]); } } + _updateRetainedTiles(idealTileCoords: Array, zoom: number): { [string]: boolean} { + let i, coord, tile, covered; + + const retain = {}; + const checked: {[number]: boolean } = {}; + const minCoveringZoom = Math.max(zoom - SourceCache.maxOverzooming, this._source.minzoom); + + + for (i = 0; i < idealTileCoords.length; i++) { + coord = idealTileCoords[i]; + tile = this._addTile(coord); + let parentWasRequested = false; + if (tile.hasData()) { + retain[coord.id] = true; + } else { + // The tile we require is not yet loaded or does not exist. + // We are now attempting to load child and parent tiles. + + // As we descend up and down the tile pyramid of the ideal tile, we check whether the parent + // tile has been previously requested (and errored in this case due to the previous conditional) + // in order to determine if we need to request its parent. + parentWasRequested = tile.wasRequested(); + + // The tile isn't loaded yet, but retain it anyway because it's an ideal tile. + retain[coord.id] = true; + covered = true; + const overscaledZ = zoom + 1; + if (overscaledZ > this._source.maxzoom) { + // We're looking for an overzoomed child tile. + const childCoord = coord.children(this._source.maxzoom)[0]; + const childTile = this.getTile(childCoord); + if (!!childTile && childTile.hasData()) { + retain[childCoord.id] = true; + } else { + covered = false; + } + } else { + // Check all four actual child tiles. + const children = coord.children(this._source.maxzoom); + for (let j = 0; j < children.length; j++) { + const childCoord = children[j]; + const childTile = childCoord ? this.getTile(childCoord) : null; + if (!!childTile && childTile.hasData()) { + retain[childCoord.id] = true; + } else { + covered = false; + } + } + } + + if (!covered) { + + // We couldn't find child tiles that entirely cover the ideal tile. + for (let overscaledZ = zoom - 1; overscaledZ >= minCoveringZoom; --overscaledZ) { + + const parentId = coord.scaledTo(overscaledZ); + if (checked[parentId.id]) { + // Break parent tile ascent, this route has been previously checked by another child. + break; + } else { + checked[parentId.id] = true; + } + + + tile = this.getTile(parentId); + if (!tile && parentWasRequested) { + tile = this._addTile(parentId); + } + + if (tile) { + retain[parentId.id] = true; + // Save the current values, since they're the parent of the next iteration + // of the parent tile ascent loop. + parentWasRequested = tile.wasRequested(); + if (tile.hasData()) { + break; + } + } + } + } + } + } + + return retain; + } + /** * Add a tile, given its coordinate, to the pyramid. * @private diff --git a/src/source/tile.js b/src/source/tile.js index f75c8d47c51..03b0cd0dc0d 100644 --- a/src/source/tile.js +++ b/src/source/tile.js @@ -102,6 +102,10 @@ class Tile { animationLoop.set(this.fadeEndTime - Date.now()); } + wasRequested() { + return this.state === 'errored' || this.state === 'loaded' || this.state === 'reloading'; + } + /** * 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_coord.js b/src/source/tile_coord.js index 61ab88e894b..330609f7ec5 100644 --- a/src/source/tile_coord.js +++ b/src/source/tile_coord.js @@ -95,6 +95,14 @@ class TileCoord { ]; } + scaledTo(targetZ: number) { + if (targetZ <= this.z) { + return new TileCoord(targetZ, this.x >> (this.z - targetZ), this.y >> (this.z - targetZ), this.w); // parent or same + } else { + return new TileCoord(targetZ, this.x << (targetZ - this.z), this.y << (targetZ - this.z), this.w); // child + } + } + static cover(z: number, bounds: [Coordinate, Coordinate, Coordinate, Coordinate], actualZ: number, renderWorldCopies: boolean | void) { if (renderWorldCopies === undefined) { diff --git a/test/ignores.json b/test/ignores.json index 8d287290795..97c7411877c 100644 --- a/test/ignores.json +++ b/test/ignores.json @@ -11,8 +11,6 @@ "render-tests/fill-extrusion-pattern/opacity": "https://github.com/mapbox/mapbox-gl-js/issues/3327", "render-tests/geojson/inline-linestring-fill": "current behavior is arbitrary", "render-tests/line-dasharray/zoom-history": "needs investigation", - "render-tests/raster-loading/missing": "https://github.com/mapbox/mapbox-gl-js/issues/4257", - "render-tests/raster-masking/overlapping": "https://github.com/mapbox/mapbox-gl-js/issues/5003", "render-tests/regressions/mapbox-gl-js#3682": "skip - true", "render-tests/runtime-styling/image-add-alpha": "failing on Circle CI 2.0, needs investigation", "render-tests/runtime-styling/image-update-icon": "skip - https://github.com/mapbox/mapbox-gl-js/issues/4804", diff --git a/test/integration/render-tests/raster-masking/overlapping/style.json b/test/integration/render-tests/raster-masking/overlapping/style.json index 430896e4bfc..7bf70d9a603 100644 --- a/test/integration/render-tests/raster-masking/overlapping/style.json +++ b/test/integration/render-tests/raster-masking/overlapping/style.json @@ -31,7 +31,10 @@ { "id": "raster", "type": "raster", - "source": "contour" + "source": "contour", + "paint": { + "raster-fade-duration": 0 + } } ] -} \ No newline at end of file +} diff --git a/test/suite_implementation.js b/test/suite_implementation.js index ed25ca02da9..7c5c62b419f 100644 --- a/test/suite_implementation.js +++ b/test/suite_implementation.js @@ -166,7 +166,7 @@ ajax.getImage = function({ url }, callback) { callback(null, png); }); } else { - callback(error || new Error(response.statusCode)); + callback(error || {status: response.statusCode}); } }); }; diff --git a/test/unit/source/source_cache.test.js b/test/unit/source/source_cache.test.js index ac2e9e97500..2e1b9a31a51 100644 --- a/test/unit/source/source_cache.test.js +++ b/test/unit/source/source_cache.test.js @@ -407,7 +407,12 @@ test('SourceCache#update', (t) => { transform.resize(511, 511); transform.zoom = 0; - const sourceCache = createSourceCache({}); + const sourceCache = createSourceCache({ + loadTile: (tile, callback)=>{ + tile.state = 'loaded'; + callback(null); + } + }); sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -577,6 +582,7 @@ test('SourceCache#update', (t) => { transform.resize(511, 511); transform.zoom = 1; + const sourceCache = createSourceCache({ loadTile: function(tile, callback) { tile.timeAdded = Date.now(); @@ -682,6 +688,352 @@ test('SourceCache#update', (t) => { t.end(); }); +test('SourceCache#_updateRetainedTiles', (t)=> { + + t.test('loads ideal tiles if they exist', (t)=>{ + const stateCache = {}; + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = stateCache[tile.coord.id] || 'errored'; + callback(); + } + }); + + const getTileSpy = t.spy(sourceCache, 'getTile'); + const idealTile = new TileCoord(1, 1, 1); + stateCache[idealTile.id] = 'loaded'; + sourceCache._updateRetainedTiles([idealTile], 1); + t.ok(getTileSpy.notCalled); + t.deepEqual(sourceCache.getIds(), [idealTile.id]); + t.end(); + }); + + t.test('adds parent tile if ideal tile errors and no child tiles are loaded', (t)=>{ + const stateCache = {}; + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = stateCache[tile.coord.id] || 'errored'; + callback(); + } + }); + + const addTileSpy = t.spy(sourceCache, '_addTile'); + const getTileSpy = t.spy(sourceCache, 'getTile'); + + const idealTiles = [new TileCoord(1, 1, 1), new TileCoord(1, 0, 1)]; + stateCache[idealTiles[0].id] = 'loaded'; + const retained = sourceCache._updateRetainedTiles(idealTiles, 1); + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // checks all child tiles to see if they're loaded before loading parent + new TileCoord(2, 0, 2), + new TileCoord(2, 1, 2), + new TileCoord(2, 0, 3), + new TileCoord(2, 1, 3), + + // when child tiles aren't found, check and request parent tile + new TileCoord(0, 0, 0) + ]); + + // retained tiles include all ideal tiles and any parents that were loaded to cover + // non-existant tiles + t.deepEqual(retained, { + // parent + '0': true, + // 1/0/1 + '65': true, + // 1/1/1 + '97': true + }); + addTileSpy.restore(); + getTileSpy.restore(); + t.end(); + }); + + t.test('don\'t use wrong parent tile', (t)=> { + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = 'errored'; + callback(); + } + }); + + const idealTile = new TileCoord(2, 0, 0); + sourceCache._tiles[idealTile.id] = new Tile(idealTile); + sourceCache._tiles[idealTile.id].state = 'errored'; + + sourceCache._tiles[new TileCoord(1, 1, 0).id] = new Tile(new TileCoord(1, 1, 0)); + sourceCache._tiles[new TileCoord(1, 1, 0).id].state = 'loaded'; + + const addTileSpy = t.spy(sourceCache, '_addTile'); + const getTileSpy = t.spy(sourceCache, 'getTile'); + + sourceCache._updateRetainedTiles([idealTile], 2); + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // all children + new TileCoord(3, 0, 0), // not found + new TileCoord(3, 1, 0), // not found + new TileCoord(3, 0, 1), // not found + new TileCoord(3, 1, 1), // not found + // parents + new TileCoord(1, 0, 0), // not found + new TileCoord(0, 0, 0) // not found + ]); + + t.deepEqual(addTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // ideal tile + new TileCoord(2, 0, 0), + // parents + new TileCoord(1, 0, 0), // not found + new TileCoord(0, 0, 0) // not found + ]); + + addTileSpy.restore(); + getTileSpy.restore(); + t.end(); + }); + + + t.test('use parent tile when ideal tile is not loaded', (t)=>{ + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = 'loading'; + callback(); + } + }); + const idealTile = new TileCoord(1, 0, 1); + sourceCache._tiles[idealTile.id] = new Tile(idealTile); + sourceCache._tiles[idealTile.id].state = 'loading'; + sourceCache._tiles['0'] = new Tile(0, 0, 0); + sourceCache._tiles['0'].state = 'loaded'; + + const addTileSpy = t.spy(sourceCache, '_addTile'); + const getTileSpy = t.spy(sourceCache, 'getTile'); + + const retained = sourceCache._updateRetainedTiles([idealTile], 1); + + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // all children + new TileCoord(2, 0, 2), // not found + new TileCoord(2, 1, 2), // not found + new TileCoord(2, 0, 3), // not found + new TileCoord(2, 1, 3), // not found + // parents + new TileCoord(0, 0, 0), // found + ]); + + t.deepEqual(retained, { + // parent of ideal tile + '0' : true, + // ideal tile id + '65' : true + }, 'retain ideal and parent tile when ideal tiles aren\'t loaded'); + + addTileSpy.reset(); + getTileSpy.reset(); + + // now make sure we don't retain the parent tile when the ideal tile is loaded + sourceCache._tiles[idealTile.id].state = 'loaded'; + const retainedLoaded = sourceCache._updateRetainedTiles([idealTile], 1); + + t.ok(getTileSpy.notCalled); + t.deepEqual(retainedLoaded, { + // only ideal tile retained + '65' : true + }, 'only retain ideal tiles when they\'re all loaded'); + + addTileSpy.restore(); + getTileSpy.restore(); + + + t.end(); + }); + + t.test('prefer loaded child tiles to parent tiles', (t)=>{ + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = 'loading'; + callback(); + } + }); + const idealTile = new TileCoord(1, 0, 0); + const loadedTiles = [new TileCoord(0, 0, 0), new TileCoord(2, 0, 0)]; + loadedTiles.forEach((t)=>{ + sourceCache._tiles[t.id] = new Tile(t); + sourceCache._tiles[t.id].state = 'loaded'; + }); + + const addTileSpy = t.spy(sourceCache, '_addTile'); + const getTileSpy = t.spy(sourceCache, 'getTile'); + let retained = sourceCache._updateRetainedTiles([idealTile], 1); + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // all children + new TileCoord(2, 0, 0), + new TileCoord(2, 1, 0), + new TileCoord(2, 0, 1), + new TileCoord(2, 1, 1), + // parent + new TileCoord(0, 0, 0) + ]); + + t.deepEqual(retained, { + // parent of ideal tile (0, 0, 0) (only partially covered by loaded child + // tiles, so we still need to load the parent) + '0' : true, + // ideal tile id (1, 0, 0) + '1' : true, + // loaded child tile (2, 0, 0) + '2': true + }, 'retains children and parent when ideal tile is partially covered by a loaded child tile'); + + addTileSpy.restore(); + getTileSpy.restore(); + // remove child tile and check that it only uses parent tile + sourceCache._tiles['2'] = null; + retained = sourceCache._updateRetainedTiles([idealTile], 1); + + t.deepEqual(retained, { + // parent of ideal tile (0, 0, 0) (only partially covered by loaded child + // tiles, so we still need to load the parent) + '0' : true, + // ideal tile id (1, 0, 0) + '1' : true + }, 'only retains parent tile if no child tiles are loaded'); + + t.end(); + }); + + t.test('don\'t use tiles below minzoom', (t)=>{ + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = 'loading'; + callback(); + }, + minzoom: 2 + }); + const idealTile = new TileCoord(2, 0, 0); + const loadedTiles = [new TileCoord(1, 0, 0)]; + loadedTiles.forEach((t)=>{ + sourceCache._tiles[t.id] = new Tile(t); + sourceCache._tiles[t.id].state = 'loaded'; + }); + + const getTileSpy = t.spy(sourceCache, 'getTile'); + const retained = sourceCache._updateRetainedTiles([idealTile], 2); + + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // all children + new TileCoord(3, 0, 0), + new TileCoord(3, 1, 0), + new TileCoord(3, 0, 1), + new TileCoord(3, 1, 1) + ], 'doesn\'t request parent tiles bc they are lower than minzoom'); + + t.deepEqual(retained, { + // ideal tile id (1, 0, 0) + '2' : true + }, 'doesn\'t retain parent tiles below minzoom'); + + getTileSpy.restore(); + t.end(); + }); + + t.test('use overzoomed tile above maxzoom', (t)=>{ + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = 'loading'; + callback(); + }, + maxzoom: 2 + }); + const idealTile = new TileCoord(2, 0, 0); + + const getTileSpy = t.spy(sourceCache, 'getTile'); + const retained = sourceCache._updateRetainedTiles([idealTile], 2); + + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // overzoomed child + new TileCoord(3, 0, 0), + // parents + new TileCoord(1, 0, 0), + new TileCoord(0, 0, 0) + ], 'doesn\'t request childtiles above maxzoom'); + + t.deepEqual(retained, { + // ideal tile id (1, 0, 0) + '2' : true + }, 'doesn\'t retain child tiles above maxzoom'); + + getTileSpy.restore(); + t.end(); + }); + + t.test('dont\'t ascend multiple times if a tile is not found', (t)=>{ + const sourceCache = createSourceCache({ + loadTile: function(tile, callback) { + tile.state = 'loading'; + callback(); + } + }); + const idealTiles = [new TileCoord(8, 0, 0), new TileCoord(8, 1, 0)]; + + const getTileSpy = t.spy(sourceCache, 'getTile'); + sourceCache._updateRetainedTiles(idealTiles, 8); + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // child tiles + new TileCoord(9, 0, 0), + new TileCoord(9, 1, 0), + new TileCoord(9, 0, 1), + new TileCoord(9, 1, 1), + // parent tile ascent + new TileCoord(7, 0, 0), + new TileCoord(6, 0, 0), + new TileCoord(5, 0, 0), + new TileCoord(4, 0, 0), + new TileCoord(3, 0, 0), + new TileCoord(2, 0, 0), + new TileCoord(1, 0, 0), + new TileCoord(0, 0, 0), + // second ideal tile children, no parent ascent + new TileCoord(9, 2, 0), + new TileCoord(9, 3, 0), + new TileCoord(9, 2, 1), + new TileCoord(9, 3, 1) + ], 'only ascends up a tile pyramid once'); + + getTileSpy.reset(); + + const loadedTiles = [new TileCoord(4, 0, 0)]; + loadedTiles.forEach((t)=>{ + sourceCache._tiles[t.id] = new Tile(t); + sourceCache._tiles[t.id].state = 'loaded'; + }); + + sourceCache._updateRetainedTiles(idealTiles, 8); + t.deepEqual(getTileSpy.getCalls().map((c)=>{ return c.args[0]; }), [ + // child tiles + new TileCoord(9, 0, 0), + new TileCoord(9, 1, 0), + new TileCoord(9, 0, 1), + new TileCoord(9, 1, 1), + // parent tile ascent + new TileCoord(7, 0, 0), + new TileCoord(6, 0, 0), + new TileCoord(5, 0, 0), + new TileCoord(4, 0, 0), // tile is loaded, stops ascent + + // second ideal tile children, no parent ascent + new TileCoord(9, 2, 0), + new TileCoord(9, 3, 0), + new TileCoord(9, 2, 1), + new TileCoord(9, 3, 1) + ], 'ascent stops if a loaded parent tile is found'); + + getTileSpy.restore(); + t.end(); + }); + t.end(); +}); + test('SourceCache#clearTiles', (t) => { t.test('unloads tiles', (t) => { const coord = new TileCoord(0, 0, 0);