diff --git a/src/render/painter.js b/src/render/painter.js index 459491b6775..c0bce776764 100644 --- a/src/render/painter.js +++ b/src/render/painter.js @@ -373,6 +373,7 @@ class Painter { { let sourceCache; let coords = []; + let symbolCoords = []; this.currentLayer = 0; @@ -382,19 +383,26 @@ class Painter { if (layer.source !== (sourceCache && sourceCache.id)) { sourceCache = this.style.sourceCaches[layer.source]; coords = []; + symbolCoords = []; if (sourceCache) { this.clearStencil(); - coords = sourceCache.getVisibleCoordinates(); + coords = sourceCache.getVisibleCoordinates(false); + // For symbol layers in the translucent pass, we add extra tiles to + // the renderable set for cross-tile symbol fading. + // Symbol layers don't use tile clipping, so no need to render + // separate clipping masks + symbolCoords = sourceCache.getVisibleCoordinates(true); if (sourceCache.getSource().isTileClipped) { this._renderTileClippingMasks(coords); } } coords.reverse(); + symbolCoords.reverse(); } - this.renderLayer(this, (sourceCache: any), layer, coords); + this.renderLayer(this, (sourceCache: any), layer, layer.type === 'symbol' ? symbolCoords : coords); } } diff --git a/src/source/source_cache.js b/src/source/source_cache.js index bf38b3d5276..1ca53e445bb 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -54,6 +54,7 @@ class SourceCache extends Evented { _coveredTiles: {[any]: boolean}; transform: Transform; _isIdRenderable: (id: number) => boolean; + _isIdRenderableForSymbols: (id: number) => boolean; used: boolean; _state: SourceFeatureState @@ -94,6 +95,7 @@ class SourceCache extends Evented { this._maxTileCacheSize = null; this._isIdRenderable = this._isIdRenderable.bind(this); + this._isIdRenderableForSymbols = this._isIdRenderableForSymbols.bind(this); this._coveredTiles = {}; this._state = new SourceFeatureState(); @@ -190,8 +192,10 @@ class SourceCache extends Evented { return Object.keys(this._tiles).map(Number).sort(compareKeyZoom); } - getRenderableIds() { - return this.getIds().filter(this._isIdRenderable); + getRenderableIds(symbolLayer?: boolean) { + return symbolLayer ? + this.getIds().filter(this._isIdRenderableForSymbols) : + this.getIds().filter(this._isIdRenderable); } hasRenderableParent(tileID: OverscaledTileID) { @@ -203,6 +207,10 @@ class SourceCache extends Evented { } _isIdRenderable(id: number) { + return this._tiles[id] && this._tiles[id].hasData() && !this._coveredTiles[id] && !this._tiles[id].holdingForFade(); + } + + _isIdRenderableForSymbols(id: number) { return this._tiles[id] && this._tiles[id].hasData() && !this._coveredTiles[id]; } @@ -524,10 +532,29 @@ class SourceCache extends Evented { for (fadedParent in parentsForFading) { retain[fadedParent] = parentsForFading[fadedParent]; } + for (const retainedId in retain) { + // Make sure retained tiles always clear any existing fade holds + // so that if they're removed again their fade timer starts fresh. + this._tiles[retainedId].clearFadeHold(); + } // Remove the tiles we don't need anymore. const remove = keysDifference(this._tiles, retain); for (let i = 0; i < remove.length; i++) { - this._removeTile(remove[i]); + const tileID = remove[i]; + const tile = this._tiles[tileID]; + if (tile.hasSymbolBuckets && !tile.holdingForFade()) { + tile.setHoldDuration(this.map._fadeDuration); + } else if (!tile.hasSymbolBuckets || tile.symbolFadeFinished()) { + this._removeTile(tileID); + } + } + } + + releaseSymbolFadeTiles() { + for (const id in this._tiles) { + if (this._tiles[id].holdingForFade()) { + this._removeTile(id); + } } } @@ -734,6 +761,10 @@ class SourceCache extends Evented { for (let i = 0; i < ids.length; i++) { const tile = this._tiles[ids[i]]; + if (tile.holdingForFade()) { + // Tiles held for fading are covered by tiles that are closer to ideal + continue; + } const tileID = tile.tileID; const scale = Math.pow(2, this.transform.zoom - tile.tileID.overscaledZ); const queryPadding = maxPitchScaleFactor * tile.queryPadding * EXTENT / tile.tileSize / scale; @@ -763,8 +794,8 @@ class SourceCache extends Evented { return tileResults; } - getVisibleCoordinates() { - const coords = this.getRenderableIds().map((id) => this._tiles[id].tileID); + getVisibleCoordinates(symbolLayer?: boolean) { + const coords = this.getRenderableIds(symbolLayer).map((id) => this._tiles[id].tileID); for (const coord of coords) { coord.posMatrix = this.transform.calculatePosMatrix(coord.toUnwrapped()); } @@ -827,4 +858,3 @@ function isRasterType(type) { } export default SourceCache; - diff --git a/src/source/tile.js b/src/source/tile.js index 8d68351c9f7..bde0d4b7f15 100644 --- a/src/source/tile.js +++ b/src/source/tile.js @@ -91,6 +91,9 @@ class Tile { resourceTiming: ?Array; queryPadding: number; + symbolFadeHoldUntil: ?number; + hasSymbolBuckets: boolean; + /** * @param {OverscaledTileID} tileID * @param size @@ -103,6 +106,7 @@ class Tile { this.buckets = {}; this.expirationTime = null; this.queryPadding = 0; + this.hasSymbolBuckets = false; // Counts the number of times a response was already expired when // received. We're using this to add a delay when making a new request @@ -164,11 +168,15 @@ class Tile { this.collisionBoxArray = data.collisionBoxArray; this.buckets = deserializeBucket(data.buckets, painter.style); - if (justReloaded) { - for (const id in this.buckets) { - const bucket = this.buckets[id]; - if (bucket instanceof SymbolBucket) { + this.hasSymbolBuckets = false; + for (const id in this.buckets) { + const bucket = this.buckets[id]; + if (bucket instanceof SymbolBucket) { + this.hasSymbolBuckets = true; + if (justReloaded) { bucket.justReloaded = true; + } else { + break; } } } @@ -438,6 +446,22 @@ class Tile { } } } + + holdingForFade(): boolean { + return this.symbolFadeHoldUntil !== undefined; + } + + symbolFadeFinished(): boolean { + return !this.symbolFadeHoldUntil || this.symbolFadeHoldUntil < browser.now(); + } + + clearFadeHold() { + this.symbolFadeHoldUntil = undefined; + } + + setHoldDuration(duration: number) { + this.symbolFadeHoldUntil = browser.now() + duration; + } } export default Tile; diff --git a/src/style/style.js b/src/style/style.js index 173e5309508..7456d7e02dc 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -1030,7 +1030,7 @@ class Style extends Evented { if (!layerTiles[styleLayer.source]) { const sourceCache = this.sourceCaches[styleLayer.source]; - layerTiles[styleLayer.source] = sourceCache.getRenderableIds() + layerTiles[styleLayer.source] = sourceCache.getRenderableIds(true) .map((id) => sourceCache.getTileByID(id)) .sort((a, b) => (b.tileID.overscaledZ - a.tileID.overscaledZ) || (a.tileID.isLessThan(b.tileID) ? -1 : 1)); } @@ -1086,6 +1086,12 @@ class Style extends Evented { return needsRerender; } + _releaseSymbolFadeTiles() { + for (const id in this.sourceCaches) { + this.sourceCaches[id].releaseSymbolFadeTiles(); + } + } + // Callbacks from web workers getImages(mapId: string, params: {icons: Array}, callback: Callback<{[string]: StyleImage}>) { diff --git a/src/symbol/placement.js b/src/symbol/placement.js index 76ccba9de80..0b87cba3922 100644 --- a/src/symbol/placement.js +++ b/src/symbol/placement.js @@ -176,11 +176,11 @@ export class Placement { ); this.placeLayerBucket(symbolBucket, posMatrix, textLabelPlaneMatrix, iconLabelPlaneMatrix, scale, textPixelRatio, - showCollisionBoxes, seenCrossTileIDs, collisionBoxArray); + showCollisionBoxes, tile.holdingForFade(), seenCrossTileIDs, collisionBoxArray); } placeLayerBucket(bucket: SymbolBucket, posMatrix: mat4, textLabelPlaneMatrix: mat4, iconLabelPlaneMatrix: mat4, - scale: number, textPixelRatio: number, showCollisionBoxes: boolean, seenCrossTileIDs: { [string | number]: boolean }, + scale: number, textPixelRatio: number, showCollisionBoxes: boolean, holdingForFade: boolean, seenCrossTileIDs: { [string | number]: boolean }, collisionBoxArray: ?CollisionBoxArray) { const layout = bucket.layers[0].layout; @@ -193,6 +193,12 @@ export class Placement { for (const symbolInstance of bucket.symbolInstances) { if (!seenCrossTileIDs[symbolInstance.crossTileID]) { + if (holdingForFade) { + // Mark all symbols from this tile as "not placed", but don't add to seenCrossTileIDs, because we don't + // know yet if we have a duplicate in a parent tile that _should_ be placed. + this.placements[symbolInstance.crossTileID] = new JointPlacement(false, false, false); + continue; + } let placeText = symbolInstance.feature.text !== undefined; let placeIcon = symbolInstance.feature.icon !== undefined; diff --git a/src/ui/map.js b/src/ui/map.js index 7371ed7455c..4f59e7b01a1 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -1681,6 +1681,13 @@ class Map extends Camera { this._styleDirty = true; } + if (this.style && !this._placementDirty) { + // Since no fade operations are in progress, we can release + // all tiles held for fading. If we didn't do this, the tiles + // would just sit in the SourceCaches until the next render + this.style._releaseSymbolFadeTiles(); + } + // Schedule another render frame if it's needed. // // Even though `_styleDirty` and `_sourcesDirty` are reset in this diff --git a/test/integration/render-tests/symbol-cross-fade/chinese/expected.png b/test/integration/render-tests/symbol-cross-fade/chinese/expected.png new file mode 100644 index 00000000000..c09b8b80479 Binary files /dev/null and b/test/integration/render-tests/symbol-cross-fade/chinese/expected.png differ diff --git a/test/integration/render-tests/symbol-cross-fade/chinese/style.json b/test/integration/render-tests/symbol-cross-fade/chinese/style.json new file mode 100644 index 00000000000..ed24aa5a3c7 --- /dev/null +++ b/test/integration/render-tests/symbol-cross-fade/chinese/style.json @@ -0,0 +1,221 @@ +{ + "version": 8, + "metadata": { + "test": { + "fadeDuration": 100, + "width": 512, + "height": 512, + "description": "Line symbols are placed differently at different zoom levels: this test crosses a zoom level and renders 80% through the cross-fade. The 'wait' logic is tricky here: 1st 'wait 100' is to trigger placement at z2 after tiles have loaded, 2nd 'wait 100' is to allow z2 labels to fade in, 'wait' after 'setZoom' runs until the z3 tiles load (but doesn't elapse any time on the fake test clock), next 'wait 100' triggers a placement using the z3 and z2 tiles together, final 'wait 80' actually exercises the cross fade.", + "operations": [ + ["wait", 100], + ["wait", 100], + [ + "setZoom", + 3 + ], + ["wait"], + [ + "wait", + 100 + ], + [ + "wait", + 80 + ] + ] + } + }, + "zoom": 2, + "center": [-14.41400, 39.09187], + "sources": { + "mapbox": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "name": "氣到身什戰只白質位歡" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.4195556640625, + 39.091699613104595 + ], + [ + 102.3046875, + 39.36827914916014 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "電局今情再夜面造" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.403076171875, + 39.10022600175347 + ], + [ + 103.35937499999999, + 65.80277639340238 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "有究往極他生血通育" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.414062499999998, + 39.091699613104595 + ], + [ + -14.765625, + 82.21421714106776 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "不示有電親界因來終" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.408569335937498, + 39.091699613104595 + ], + [ + -130.78125, + 39.095962936305476 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "後再學全看素力來:不車" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.414062499999998, + 39.095962936305476 + ], + [ + -16.5234375, + -58.81374171570779 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "有下人費也家了清,黨光她保過每心" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.4195556640625, + 39.10022600175347 + ], + [ + -130.4296875, + 64.47279382008166 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "醫公藝說就公和有" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.4195556640625, + 39.0831721934762 + ], + [ + 33.75, + 81.87364125482827 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "光中輪的態指那差車" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -14.447021484374998, + 39.104488809440475 + ], + [ + -66.4453125, + 82.26169873683153 + ] + ] + } + } + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "lines-symbol", + "type": "symbol", + "source": "mapbox", + "layout": { + "text-field": "{name}", + "symbol-placement": "line", + "symbol-spacing": 100, + "text-allow-overlap": false, + "text-font": [ "NotoCJK" ] + } + }, { + "id": "lines", + "type": "line", + "source": "mapbox", + "paint": { + "line-opacity": 0.25 + } + } + ] +}